From 92648f9ba9d1ba1ee441b805cf6cb17ed9b68358 Mon Sep 17 00:00:00 2001 From: Peter Lee Date: Sun, 8 Mar 2026 01:27:01 -0600 Subject: [PATCH 001/311] fix(agents): broaden 402 temporary-limit detection and allow billing cooldown probe (#38533) Merged via squash. Prepared head SHA: 282b9186c6f48fcdbf0c81c49f739e5e9ed2df23 Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/failover-error.test.ts | 74 +++++++++++ src/agents/model-fallback.probe.test.ts | 62 +++++++++ src/agents/model-fallback.ts | 28 ++++- ...dded-helpers.isbillingerrormessage.test.ts | 82 ++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 118 +++++++++++++++--- src/agents/pi-embedded-runner/run.ts | 4 +- 7 files changed, 343 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00eefe4e2770d..2902617108f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -361,6 +361,7 @@ Docs: https://docs.openclaw.ai - Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow. - ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps. - Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus. +- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee. ## 2026.3.2 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index f581dd0ede24d..a99cfb5c4b261 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -18,6 +18,8 @@ const GEMINI_RESOURCE_EXHAUSTED_MESSAGE = "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota)."; // OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; +const TOGETHER_MONTHLY_SPEND_CAP_MESSAGE = + "The account associated with this API key has reached its maximum allowed monthly spending limit."; // Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: // https://github.com/openclaw/openclaw/issues/23440 const INSUFFICIENT_QUOTA_PAYLOAD = @@ -182,6 +184,78 @@ describe("failover-error", () => { ).toBe("billing"); }); + it("keeps temporary 402 spend limits retryable without downgrading explicit billing", () => { + expect( + resolveFailoverReasonFromError({ + status: 402, + message: "Monthly spend limit reached. Please visit your billing settings.", + }), + ).toBe("rate_limit"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message: "Workspace spend limit reached. Contact your admin.", + }), + ).toBe("rate_limit"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message: `${"x".repeat(520)} insufficient credits. Monthly spend limit reached.`, + }), + ).toBe("billing"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message: TOGETHER_MONTHLY_SPEND_CAP_MESSAGE, + }), + ).toBe("billing"); + }); + + it("keeps raw 402 wrappers aligned with status-split temporary spend limits", () => { + const message = "Monthly spend limit reached. Please visit your billing settings."; + expect( + resolveFailoverReasonFromError({ + message: `402 Payment Required: ${message}`, + }), + ).toBe("rate_limit"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message, + }), + ).toBe("rate_limit"); + }); + + it("keeps explicit 402 rate-limit wrappers aligned with status-split payloads", () => { + const message = "rate limit exceeded"; + expect( + resolveFailoverReasonFromError({ + message: `HTTP 402 Payment Required: ${message}`, + }), + ).toBe("rate_limit"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message, + }), + ).toBe("rate_limit"); + }); + + it("keeps plan-upgrade 402 wrappers aligned with status-split billing payloads", () => { + const message = "Your usage limit has been reached. Please upgrade your plan."; + expect( + resolveFailoverReasonFromError({ + message: `HTTP 402 Payment Required: ${message}`, + }), + ).toBe("billing"); + expect( + resolveFailoverReasonFromError({ + status: 402, + message, + }), + ).toBe("billing"); + }); + it("infers format errors from error messages", () => { expect( resolveFailoverReasonFromError({ diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index bcb66628d66fc..01bcb2dc3a866 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -345,4 +345,66 @@ describe("runWithModelFallback – probe logic", () => { allowTransientCooldownProbe: true, }); }); + + it("skips billing-cooldowned primary when no fallback candidates exist", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: [], + }, + }, + }, + } as Partial); + + // Billing cooldown far from expiry — would normally be skipped + const expiresIn30Min = NOW + 30 * 60 * 1000; + mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min); + mockedResolveProfilesUnavailableReason.mockReturnValue("billing"); + + await expect( + runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + fallbacksOverride: [], + run: vi.fn().mockResolvedValue("billing-recovered"), + }), + ).rejects.toThrow("All models failed"); + }); + + it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => { + const cfg = makeCfg(); + // Cooldown expires in 1 minute — within 2-min probe margin + const expiresIn1Min = NOW + 60 * 1000; + mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn1Min); + mockedResolveProfilesUnavailableReason.mockReturnValue("billing"); + + const run = vi.fn().mockResolvedValue("billing-probe-ok"); + + const result = await runPrimaryCandidate(cfg, run); + + expect(result.result).toBe("billing-probe-ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); + }); + + it("skips billing-cooldowned primary with fallbacks when far from cooldown expiry", async () => { + const cfg = makeCfg(); + const expiresIn30Min = NOW + 30 * 60 * 1000; + mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min); + mockedResolveProfilesUnavailableReason.mockReturnValue("billing"); + + const run = vi.fn().mockResolvedValue("ok"); + + const result = await runPrimaryCandidate(cfg, run); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("anthropic", "claude-haiku-3-5"); + expect(result.attempts[0]?.reason).toBe("billing"); + }); }); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 0094ef731fc11..ad2b5759233fe 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -419,11 +419,23 @@ function resolveCooldownDecision(params: { profileIds: params.profileIds, now: params.now, }) ?? "rate_limit"; - const isPersistentIssue = - inferredReason === "auth" || - inferredReason === "auth_permanent" || - inferredReason === "billing"; - if (isPersistentIssue) { + const isPersistentAuthIssue = inferredReason === "auth" || inferredReason === "auth_permanent"; + if (isPersistentAuthIssue) { + return { + type: "skip", + reason: inferredReason, + error: `Provider ${params.candidate.provider} has ${inferredReason} issue (skipping all models)`, + }; + } + + // Billing is semi-persistent: the user may fix their balance, or a transient + // 402 might have been misclassified. Probe the primary only when fallbacks + // exist; otherwise repeated single-provider probes just churn the disabled + // auth state without opening any recovery path. + if (inferredReason === "billing") { + if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) { + return { type: "attempt", reason: inferredReason, markProbe: true }; + } return { type: "skip", reason: inferredReason, @@ -518,7 +530,11 @@ export async function runWithModelFallback(params: { if (decision.markProbe) { lastProbeAttempt.set(probeThrottleKey, now); } - if (decision.reason === "rate_limit" || decision.reason === "overloaded") { + if ( + decision.reason === "rate_limit" || + decision.reason === "overloaded" || + decision.reason === "billing" + ) { runOptions = { allowTransientCooldownProbe: true }; } } diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 4919bc607c0a6..8649f46f8712b 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { classifyFailoverReason, + classifyFailoverReasonFromHttpStatus, isAuthErrorMessage, isAuthPermanentErrorMessage, isBillingErrorMessage, @@ -505,6 +506,87 @@ describe("image dimension errors", () => { }); }); +describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => { + it("reclassifies periodic usage limits as rate_limit", () => { + const samples = [ + "Monthly spend limit reached.", + "Weekly usage limit exhausted.", + "Daily limit reached, resets tomorrow.", + ]; + for (const sample of samples) { + expect(classifyFailoverReasonFromHttpStatus(402, sample)).toBe("rate_limit"); + } + }); + + it("reclassifies org/workspace spend limits as rate_limit", () => { + const samples = [ + "Organization spending limit exceeded.", + "Workspace spend limit reached.", + "Organization limit exceeded for this billing period.", + ]; + for (const sample of samples) { + expect(classifyFailoverReasonFromHttpStatus(402, sample)).toBe("rate_limit"); + } + }); + + it("keeps 402 as billing when explicit billing signals are present", () => { + expect( + classifyFailoverReasonFromHttpStatus( + 402, + "Your credit balance is too low. Monthly limit exceeded.", + ), + ).toBe("billing"); + expect( + classifyFailoverReasonFromHttpStatus( + 402, + "Insufficient credits. Organization limit reached.", + ), + ).toBe("billing"); + expect( + classifyFailoverReasonFromHttpStatus( + 402, + "The account associated with this API key has reached its maximum allowed monthly spending limit.", + ), + ).toBe("billing"); + }); + + it("keeps long 402 payloads with explicit billing text as billing", () => { + const longBillingPayload = `${"x".repeat(520)} insufficient credits. Monthly spend limit reached.`; + expect(classifyFailoverReasonFromHttpStatus(402, longBillingPayload)).toBe("billing"); + }); + + it("keeps 402 as billing without message or with generic message", () => { + expect(classifyFailoverReasonFromHttpStatus(402, undefined)).toBe("billing"); + expect(classifyFailoverReasonFromHttpStatus(402, "")).toBe("billing"); + expect(classifyFailoverReasonFromHttpStatus(402, "Payment required")).toBe("billing"); + }); + + it("matches raw 402 wrappers and status-split payloads for the same message", () => { + const transientMessage = "Monthly spend limit reached. Please visit your billing settings."; + expect(classifyFailoverReason(`402 Payment Required: ${transientMessage}`)).toBe("rate_limit"); + expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit"); + + const billingMessage = + "The account associated with this API key has reached its maximum allowed monthly spending limit."; + expect(classifyFailoverReason(`402 Payment Required: ${billingMessage}`)).toBe("billing"); + expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing"); + }); + + it("keeps explicit 402 rate-limit messages in the rate_limit lane", () => { + const transientMessage = "rate limit exceeded"; + expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe( + "rate_limit", + ); + expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit"); + }); + + it("keeps plan-upgrade 402 limit messages in billing", () => { + const billingMessage = "Your usage limit has been reached. Please upgrade your plan."; + expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing"); + expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing"); + }); +}); + describe("classifyFailoverReason", () => { it("classifies documented provider error messages", () => { expect(classifyFailoverReason(OPENAI_RATE_LIMIT_MESSAGE)).toBe("rate_limit"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 5e4fc4c541eab..cd4701c9db916 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -208,6 +208,100 @@ const HTTP_ERROR_HINTS = [ "permission", ]; +type PaymentRequiredFailoverReason = Extract; + +const BILLING_402_HINTS = [ + "insufficient credits", + "insufficient quota", + "credit balance", + "insufficient balance", + "plans & billing", + "add more credits", + "top up", +] as const; +const BILLING_402_PLAN_HINTS = [ + "upgrade your plan", + "upgrade plan", + "current plan", + "subscription", +] as const; + +const PERIODIC_402_HINTS = ["daily", "weekly", "monthly"] as const; +const RETRYABLE_402_RETRY_HINTS = ["try again", "retry", "temporary", "cooldown"] as const; +const RETRYABLE_402_LIMIT_HINTS = ["usage limit", "rate limit", "organization usage"] as const; +const RETRYABLE_402_SCOPED_HINTS = ["organization", "workspace"] as const; +const RETRYABLE_402_SCOPED_RESULT_HINTS = [ + "billing period", + "exceeded", + "reached", + "exhausted", +] as const; +const RAW_402_MARKER_RE = + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b/i; +const LEADING_402_WRAPPER_RE = + /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; + +function includesAnyHint(text: string, hints: readonly string[]): boolean { + return hints.some((hint) => text.includes(hint)); +} + +function hasExplicit402BillingSignal(text: string): boolean { + return ( + includesAnyHint(text, BILLING_402_HINTS) || + (includesAnyHint(text, BILLING_402_PLAN_HINTS) && text.includes("limit")) || + text.includes("billing hard limit") || + text.includes("hard limit reached") || + (text.includes("maximum allowed") && text.includes("limit")) + ); +} + +function hasRetryable402TransientSignal(text: string): boolean { + const hasPeriodicHint = includesAnyHint(text, PERIODIC_402_HINTS); + const hasSpendLimit = text.includes("spend limit") || text.includes("spending limit"); + const hasScopedHint = includesAnyHint(text, RETRYABLE_402_SCOPED_HINTS); + return ( + (includesAnyHint(text, RETRYABLE_402_RETRY_HINTS) && + includesAnyHint(text, RETRYABLE_402_LIMIT_HINTS)) || + (hasPeriodicHint && (text.includes("usage limit") || hasSpendLimit)) || + (hasPeriodicHint && text.includes("limit") && text.includes("reset")) || + (hasScopedHint && + text.includes("limit") && + (hasSpendLimit || includesAnyHint(text, RETRYABLE_402_SCOPED_RESULT_HINTS))) + ); +} + +function normalize402Message(raw: string): string { + return raw.trim().toLowerCase().replace(LEADING_402_WRAPPER_RE, "").trim(); +} + +function classify402Message(message: string): PaymentRequiredFailoverReason { + const normalized = normalize402Message(message); + if (!normalized) { + return "billing"; + } + + if (hasExplicit402BillingSignal(normalized)) { + return "billing"; + } + + if (isRateLimitErrorMessage(normalized)) { + return "rate_limit"; + } + + if (hasRetryable402TransientSignal(normalized)) { + return "rate_limit"; + } + + return "billing"; +} + +function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailoverReason | null { + if (!RAW_402_MARKER_RE.test(raw)) { + return null; + } + return classify402Message(raw); +} + function extractLeadingHttpStatus(raw: string): { code: number; rest: string } | null { const match = raw.match(HTTP_STATUS_CODE_PREFIX_RE); if (!match) { @@ -261,25 +355,7 @@ export function classifyFailoverReasonFromHttpStatus( } if (status === 402) { - // Some providers (e.g. Anthropic Claude Max plan) surface temporary - // usage/rate-limit failures as HTTP 402. Use a narrow matcher for - // temporary limits to avoid misclassifying billing failures (#30484). - if (message) { - const lower = message.toLowerCase(); - // Temporary usage limit signals: retry language + usage/limit terminology - const hasTemporarySignal = - (lower.includes("try again") || - lower.includes("retry") || - lower.includes("temporary") || - lower.includes("cooldown")) && - (lower.includes("usage limit") || - lower.includes("rate limit") || - lower.includes("organization usage")); - if (hasTemporarySignal) { - return "rate_limit"; - } - } - return "billing"; + return message ? classify402Message(message) : "billing"; } if (status === 429) { return "rate_limit"; @@ -858,6 +934,10 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (isModelNotFoundErrorMessage(raw)) { return "model_not_found"; } + const reasonFrom402Text = classifyFailoverReasonFrom402Text(raw); + if (reasonFrom402Text) { + return reasonFrom402Text; + } if (isPeriodicUsageLimitErrorMessage(raw)) { return isBillingErrorMessage(raw) ? "billing" : "rate_limit"; } diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 80ef934d63eef..c763fbd2a94cc 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -668,7 +668,9 @@ export async function runEmbeddedPiAgent( const allowTransientCooldownProbe = params.allowTransientCooldownProbe === true && allAutoProfilesInCooldown && - (unavailableReason === "rate_limit" || unavailableReason === "overloaded"); + (unavailableReason === "rate_limit" || + unavailableReason === "overloaded" || + unavailableReason === "billing"); let didTransientCooldownProbe = false; while (profileIndex < profileCandidates.length) { From d3c3d0e730d1c1034f6996b5c50d353b515ada6b Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 13:13:25 +0530 Subject: [PATCH 002/311] style(android): update app icon --- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 10928 -> 3356 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 44702 -> 7097 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 5655 -> 2104 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 21883 -> 4473 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 17855 -> 4712 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 75167 -> 10008 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 36404 -> 7355 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 158927 -> 16420 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 60935 -> 10490 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 276047 -> 23238 bytes .../app/src/main/res/values/colors.xml | 2 +- 11 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 613e26663836ac2bb973b6af97d0072e5469fd01..c4ed5c6bc213f6d8826e0d39e17a83f3985396f9 100644 GIT binary patch literal 3356 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 G7y1u9-Vn?H literal 10928 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 diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 22442bc1d80373b884c11241ab136536e8215e7c..0f982efa98f164dc056c31df4f516054f8535353 100644 GIT binary patch literal 7097 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 literal 44702 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 FV1g-lDoFqU diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index b1fd747de01176d1891e89132524fb6f42665fd1..0a356f45fe9af52bb17940769b2648c63fe4b581 100644 GIT binary patch delta 2063 zcmV+q2=MopEVvMm8Gi-<00374`G)`i00d`2O+f$vv5yP zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u0C;6sNoGw=04e|g00;mC0U7`W z000010000!0UH1X000010000+0Vn_i0000100IC2hiL!=02=@R0RR91P5=M^00000 zYybcN0RR91YybcN0g+HDf5$kQ0ssI232;bRa{vGkoB#k2oB<}yIy3+P2P#QKK~!i3 z?O17SR8~UovutvDNrHo3W5cstilhY1|y=V><9=^Ll86u5Td9M(O^K3tzjz^ z5Q&M2CPbql0w(fA^hx&b?>7->a09 zVpj1GlkOotyEuBQ0WBTD)(EslpqU? zF4EZ)(nl<$s&r^SB%KbjEeVp*1n<=xfT1A8Q8Jn6M~X}x>vFpTvLs>X(iN)51OL4O zsEPtfmLt{`nE|vRe@LmJQyRS2a$wt(4P%#XC|Umn3|+emUeU^>P>Sx8F&bG(2h_#J zA%5}4WPl3qKbH~n<^uR`UPu1(qhLtu0^hB?Ktv*Nh!hE&R^1-Ny}JalbLYc-@f<9J zhlxW6-R&>9jogus!+*a}ypoK@AS>+2US^Wx;*omj4D{_ge^m1oKb*&oz|`welx+D5 z#jBQ6RO{ALEhK^;I<0%GdK4&*Qu16pEEyxi4$l(y?VI4sy$fUaUa*W94b|(drHJ2I zhCc;k&pzQtfFOW?@f z27RX#ByIf$e~ByB1nPm)E=V+%3`Qh=vKq;>MxT~SGTWgP7X?>@J&nB|gQ-957pfy{ zMfId%11c5RHfAAu;*4skkWtxp``|CUkK}E8VeHWx`jn2)+mZoFi7KMJ=fc;gc7~x_ zPg+Ydb~_vg_Eaqi9Ynu64M~~VfkM_WqG1ELh)6-%fBs$YbAi?%)mZ_@{wySI&Vu>L z;g#Hg=M}e4q!Oo&>aI+w%EOokWguzGZj>F!hEiT$Lo3-%k#X$Hq5-9mMj>k#apk5R zsp!~yq2xM#1m5d8h<;^i)oahCi^vdz;^=@BHy{@pO7 zc7l0Gf6({<$9we(a)(n2aTGPWnsyr{19Z_=NNu8m`&K|HBj~b(trM#l(vHgLx&J*P?X$#s+mc zfA;PWd)C~ht~sgHCcar+r%#~pgLmM&onK49mwN;Emn{tBvtEXE61QCHVC=z4FGf## zoj?zjoXaobMp#Sx7G?zz8NdrUSM8hDB5%|HdamJDRiy9G z5w`U^s7v*Nq4Og!XN;=i1qT-DG={XUe}tWS(Aw=VbW97f#M7R+E})yE$c9Epk*_3$ zlG#jn=N>r3rYEQar4I>gLqIR;*%#nGb4q|cT~riZZg*+Dg;GkUPUL~k8cVlpI$djy*dFjk1h6jHfkelmV=aVYB=ES=^a6UVyr`<0pzfF2^UpbKxA!f5QkX z5#3ubhl;tKNFj#dwl$eBQ`_ZM%EVpGb?h+8zRia7_+j`9?$F^0k~fkXZGh0PsEC>Q z7W7H&MLIE*mfP9jK6eJzX|u%=ALY1@P^#*53iQb@4PCpzwdy0>8aGCGn~iQeVKR(; z`=e<2Vj#J@fU>g^?|z~cHaA<;Q7NP2m~eK z>FMT;ne{e|1P&SdGgQeROHtFfaqBazNd`zp16*`3+Gp9v;crO1XSe^@Kg|5(bjTtxDfZ!$x%T|gH z9T(vqUtS)(Iah+LT2Bx0Gp(YYOQO`i-_-mT1v_5jFIjwm z!+GS8fX#A3VwfZYwA?Z2;0w)vw*amaM+Mlc3(SQHYD}7KfY48!M0~K*le>v^`W(c~ zUnDLq<}>^#S@Rj}TfYuqAc~$S+De$AamEJO55|BRTc{p4p--EDX%&Web5JGV#w6*fS2VHD3k41QG`kDctD6qo)ZSjN&N>)P??QDbYl&B_2DF6N tL-%ZrK&UrbV%!>mmhgJ$o)3Kl{so1joM;$_@2&s<002ovPDHLkV1ko%vS|PS literal 5655 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 diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index d26c0189852d3e650da64609a593c897b1f9d4ca..7b5c8198c1f028e76f821184ae85c87a6d6baf02 100644 GIT binary patch literal 4473 zcmb_g^;Z<$_MIW5h8RGkyOA0Y5crJb&@j?5;GlFPJ*0H^pr9a~LkNh1l0!Grh=epq zH;8=k``&u*A9#1IefK{5?6cPS_1+k`whAdRJuv_PAXQUU(!FD+zaYGKcPH5x4c!sY zLsvxsP&LfBbyu*yP}S7}08rcjKu9(}Vy#AUy#8P66)#1v30c!+^X1-2eA+0Dvfa0N%ed+IRf-{N3m8%)b~n z7x?e=otKOApR`jh?tk^)v9*bZ6n8}ArfT8=08qU93n2H9wD_HHQB#uF_XX}Eh};;v z?q|>HeR948fVzRue`hxqqM40Gf#h;>_ zAuw-zuJJI|kiCvmO=7{u4?ZkYu&}DZIP=-Py!PqyXZt;)2Qy*!t|Zxv&K2{0mT>u& zswr`SGBmy=)MQ@n=1k{&1Bupe935L!K=;1Xuu2sn+sJ|qSih@$Em{$s7{3Y9L=vjv z?p3GPCMRKn+Huk#&fg;3E%Piu5mRwcSO3E0ne0(OM#WsKDlvZF>+&u6z~;Bz70bO_ zzGi7~IZk)5Oz_14CHvda`GB;|^N)lXXTp^c5nC%<`=wK1agv&^>{se&0kR_;NhP{J zFOd(O--K|o2h7yJExC@hbygXV|Fc z!8Q16yY1uy2FFL<*(-~l1D%~xIf!`O=*n{ zuNrO@@QcaI#ebb{C1774&cq;L6#|K%ifI~t=N>ez+y~<1<;IRTc|WJxH?~huLII_67T+L zqL7oF4OW$Nl>P*!3|!s&fjgD58AV+Vtxx^sMLk>23Q9wl5fjp&(fr)~(a{|U^W7VW z&g#};@u2o2P)63v%xSx3mEiCQ?4%Uso-HzqE++YTt3MNniw_Za&|Qxow_vHo?V$^8 z9xgOd6AOT*i|dr=h^-F=tJpc3F&7F z2<6vy3;&Aw($T#i8!F!Dh1KsCkZ0O)>ASrg_RHBa!s+9m=T^)?0XHvY#~8MAG8#5* z-@7jcO6nZ>-)2YK^wytv>aBZ8%7oY`)eRHDNjpiv?mxs>Z}E! zx0c3XV5S2q1wr^zruEe7^+agQ=5hi(iG>8jqM;+AsoZDh(?5f&GoG*-9$kA2_;Hb7 zH>H`Yq}`f3BlL6++vh&`VBDC}z%=-x)LAL9qnP$&>)}?E*xMAph>VwR)OA#-oz4dc z#A64cFvQ(yA1m2Dk{PcW^}^4_z}+6(Ih(iavpm?AO>v7ML4`2wG6VRZ;Y z4N;iw=m1L;_mzHhHwJ4QmZG6dk^87kyxf^1KAdo6`x?>3GN{0_LMcJNR;H>=^#CFXTyZWv-8mSc9$#ZQ+=)WNTSrpC;X$=#MW*<7;am&h;J0)Vf5PYG3G!DN83# z#QkTpcAZ6k7S6?J5bp3kjrVUom>n6Pw-!sj@!Xnyz&l`N?l%cu^(Ke>=C_A2!t&Ky zz{hHEw?S#pV}F5V5;E>mk4`lyYP%#xsow}$Tqo^T(6{G$7E4IKaPua8c(G+c%xGzj zoT4JwE;OOMQ!jzFGseZItE`sp{HSKTHrHWay%c4^&*?ViCvq~n zUZPTcVo6S|?=doy_KY@)@ipGSK+LJ-DAuRppVV049=LO?_{0;5($K!tkO7I}_F27M zIO#+x!G?w~r=ytbE}|%cAa6N)c(U)ptukSx9h_)rNVKIiA{XbsJC}au)VKQBd8;&U zPe_D=%W0`5CZCsBd*ewe(PHfxk(jZVcrb;olyQ`#i>5}mj-5RUD6JJ}D1k}lA!^I- zV0z;13Ry_w&vtdV$fiAzbaeirNwDqiqtdREio-%z*Sr^+#r+s%4kr8n~#E( zxsUH35w1^1yMnDc-9Ea-E7@Ioz7=akV>^{NU-BBNjYH8zCH~o7UV~(%4qs+Au2{LO zUeQVnzAu0CE}p`cap7AdjZ@N`l_K(p9IxEBWQA{W`dP)9Z_I_xTs=fY{*YAVxP9PE zuo8*QTVEJm?yo>6>PsO)=iep|VJHqc9Wxa*V6)SOm` zM&4HqTS<&kY7L$V(||+%D9nTh@n&-DsKRK~)GCFrEbjQ>yUHxq=$3$|^K zN)_NDa3-lyPLv{iD$+4}DZr=)tDd~_qpxF4Mi^xDD@Q<*Qd;x@EJnRxRpxs^u~5t5 zj4tR3^t%z6^$%e9{Z8XqhC;Bjvu0W+e*7y>Mkmph10y5yEl2igKUjY z;W2$)-DzfTmHAki`YiIZv`kt>rFr!EZX9znNki3!0TT^~nO_&-nlLeeP?y$PUyeE& z-QsTr9gGL3too#*i`-9DXf8Bb6TOlVWU?je2S3gXtoCWqqSU`wr|v(##RQNvH&^qv z6TF8BYsP)I6g2Ico)Su#^yQMuFS)fgppz$T%MyP-N;s9sF15Of%sTP-(NJ2zH|-gK z?-cz)kC!hFW4_Ppms;a7l%?5HM|e}X1l+E{BnXjWAR+PTVyvD`YgLV zOf9PM-9Fgj^GB$T=i_Mw0_P}4*shyy&gcOV1QzP=ty0~a`qK6k*YO)m{S02rlu8JW z%8=I~6UA;l@VsiNrP=2w>dd@r|GUq9OqhVBx%wty>@RZ$qH6eL-ein#**Byc@le7D z#nt$YCgFJOR!7_)+^EF-<@sZa=UO(CTu?4K>E@Jo>|##C6Hx0W#wnelX3>>xYe`7z zW?Q^Ad6maTuiv4o)T^^pai)jw5B>V3A}e%qoqi9A@7GSFw@L&8P!E}iOdYfQ$IKMW zhdw?}^59tS$id@`hsuaKG`^O-Nm05S6KA4T02R+FdeP*CCDKQ?*gm815RA@va!Fnc zOIqCveBxPnCOtkM$D?Em9^>vLOnU{{5OxeX;K)sFMP{`gkYU6YDm8Szgo@JJen841(8+9H~?Y`^h8 z&Ws0f2R?-!eny4;PAMS4c3k=upA5>jZ|rhYs3JDgWi*!_Gc4o#H&-9X-GU95vasGp z9>Yg#E~(ng^9!F zJpBlz{S|_pd}B&}ruF5v`Fz-7ATmRk>Z3#@V8H*mD=n>lN_n7PI5{HYyP%I;x$EXT zb*X7fMS|crxDS?IFctp9ts+2pU~x^Yn02y<3b^_cBvyxRh3#JHgl0$>|B%R{^`09P zAQD{X;5d62spa;lG}8FB*oyiL3}o7~9F_uCxjtU^dmYR^@aX9RRTY*{R2#A z07=c1K+vCz)|30@##t{3)p2ADqU77obbJTdjl6(JQA_iCnHkX@uQ$`aS6RWbF7fjB zFG+vj1Cm^E8~Zp|TAk#!w3OO9FCk|eMY2gti$zJnOJITq~3bq zvZc0`F71yGSro2o{;CI4<#{dU&<;0tY0#w+Ha$e%m$ zU6Zn2<}gY VnN|j}QvLmwP*c`cs!~9P{vTxmH%R~h literal 21883 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 Hu0mjfHk*Ht diff --git a/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 038e3dc7a7091109da0b257c830a0e85a078c4cb..df60cf7f247932df174e29988b42dc86ac21ef86 100644 GIT binary patch literal 4712 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 literal 75167 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 M07*qoM6N<$g6i--WdHyG diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index a5d995c2ee236b987512e04d4c77bb147d648824..c267f5ce17f2a8d36945f39fff29e72985e41a63 100644 GIT binary patch literal 7355 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 z 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

8CAw9^Et0+Ik1P$G%J3(c~@W*pws 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 Z{}(i@ivdtk##8_R002ovPDHLkV1m32Yd`=1 diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index ceabff1f5626ea98dec97ecaa017ff2b27e6fe8b..2f6ec1435bb85e0223755f05d7d30f847f9ff71e 100644 GIT binary patch literal 10490 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 literal 60935 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

CJIyW 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�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

nh%H3|uTrZt}2Zqr5kzig$6Gv`*Och#43v2d0k> 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

#-~bacT_zoo>2czsITYC+{&^+irjUp#D?W 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
dVxsQ8BG!t+R&>hk^@y5PK>mp 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 T`f7G300000NkvXXu0mjfBO3e< diff --git a/apps/android/app/src/main/res/values/colors.xml b/apps/android/app/src/main/res/values/colors.xml index dfadc94cf0354..561303031c3c4 100644 --- a/apps/android/app/src/main/res/values/colors.xml +++ b/apps/android/app/src/main/res/values/colors.xml @@ -1,3 +1,3 @@ - #0A0A0A + #DD1A08 From 6477da623f669dc3f9b9a125fb766e0cea78353c Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 13:24:48 +0530 Subject: [PATCH 003/311] chore(secrets): sync detect-secrets baseline --- .secrets.baseline | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index dbaaddfaf7bb1..1a5c116737c45 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -183,7 +183,7 @@ { "type": "Base64 High Entropy String", "filename": "appcast.xml", - "hashed_secret": "abb0380989460de3f211d60628b439de7ebcd482", + "hashed_secret": "5e1b354be110a63154f710e31bf13f2b70431217", "is_verified": false, "line_number": 364 }, @@ -9795,63 +9795,63 @@ "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 1611 + "line_number": 1612 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", "is_verified": false, - "line_number": 1627 + "line_number": 1628 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", "is_verified": false, - "line_number": 1812 + "line_number": 1813 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 1985 + "line_number": 1986 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 2041 + "line_number": 2042 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 2273 + "line_number": 2274 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2401 + "line_number": 2402 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 2654 + "line_number": 2655 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 2656 + "line_number": 2657 } ], "docs/gateway/configuration.md": [ @@ -9945,7 +9945,7 @@ "filename": "docs/help/faq.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2489 + "line_number": 2490 } ], "docs/install/macos-vm.md": [ @@ -10033,14 +10033,14 @@ "filename": "docs/providers/minimax.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 70 + "line_number": 69 }, { "type": "Secret Keyword", "filename": "docs/providers/minimax.md", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 149 + "line_number": 148 } ], "docs/providers/moonshot.md": [ From 4db634964b93f5b1f64746fbd1f4777f1f895d81 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 13:29:26 +0530 Subject: [PATCH 004/311] chore(secrets): sync appcast baseline --- .secrets.baseline | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 1a5c116737c45..2e441d4608561 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -183,23 +183,23 @@ { "type": "Base64 High Entropy String", "filename": "appcast.xml", - "hashed_secret": "5e1b354be110a63154f710e31bf13f2b70431217", + "hashed_secret": "7afea670e53d801f1f881c99c40aa177e3395bfa", "is_verified": false, - "line_number": 364 + "line_number": 365 }, { "type": "Base64 High Entropy String", "filename": "appcast.xml", "hashed_secret": "6e1ba26139ac4e73427e68a7eec2abf96bcf1fd4", "is_verified": false, - "line_number": 583 + "line_number": 584 }, { "type": "Base64 High Entropy String", "filename": "appcast.xml", "hashed_secret": "c0baa9660a8d3b11874c63a535d8369f4a8fa8fa", "is_verified": false, - "line_number": 722 + "line_number": 723 } ], "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [ From 9425209602143aef73288fe61edc47b23fbc5eb2 Mon Sep 17 00:00:00 2001 From: Daniel Hnyk Date: Sun, 8 Mar 2026 09:43:13 +0100 Subject: [PATCH 005/311] fix(mattermost): pass payload.replyToId as root_id for threaded replies (#27744) Merged via squash. Prepared head SHA: e02907987221bd6ced0a93ff6fcff58531283150 Co-authored-by: hnykda <2741256+hnykda@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm --- CHANGELOG.md | 1 + .../mattermost/src/mattermost/monitor.test.ts | 24 +++++++++++ .../mattermost/src/mattermost/monitor.ts | 21 +++++++++- src/auto-reply/reply/reply-plumbing.test.ts | 40 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2902617108f8d..cc52f4a82738b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Fixes - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. +- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. ## 2026.3.7 diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts index ab122948ebc79..1bd871714c4da 100644 --- a/extensions/mattermost/src/mattermost/monitor.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveMattermostAccount } from "./accounts.js"; import { evaluateMattermostMentionGate, + resolveMattermostReplyRootId, type MattermostMentionGateInput, type MattermostRequireMentionResolverInput, } from "./monitor.js"; @@ -107,3 +108,26 @@ describe("mattermost mention gating", () => { expect(decision.dropReason).toBe("missing-mention"); }); }); + +describe("resolveMattermostReplyRootId", () => { + it("uses replyToId for top-level replies", () => { + expect( + resolveMattermostReplyRootId({ + replyToId: "inbound-post-123", + }), + ).toBe("inbound-post-123"); + }); + + it("keeps the thread root when replying inside an existing thread", () => { + expect( + resolveMattermostReplyRootId({ + threadRootId: "thread-root-456", + replyToId: "child-post-789", + }), + ).toBe("thread-root-456"); + }); + + it("falls back to undefined when neither reply target is available", () => { + expect(resolveMattermostReplyRootId({})).toBeUndefined(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index a7b692c2c7427..d6f4bd9543c43 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -271,6 +271,17 @@ export function evaluateMattermostMentionGate( dropReason: null, }; } + +export function resolveMattermostReplyRootId(params: { + threadRootId?: string; + replyToId?: string; +}): string | undefined { + const threadRootId = params.threadRootId?.trim(); + if (threadRootId) { + return threadRootId; + } + return params.replyToId?.trim() || undefined; +} type MattermostMediaInfo = { path: string; contentType?: string; @@ -1651,7 +1662,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } await sendMessageMattermost(to, chunk, { accountId: account.accountId, - replyToId: threadRootId, + replyToId: resolveMattermostReplyRootId({ + threadRootId, + replyToId: payload.replyToId, + }), }); } } else { @@ -1662,7 +1676,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} await sendMessageMattermost(to, caption, { accountId: account.accountId, mediaUrl, - replyToId: threadRootId, + replyToId: resolveMattermostReplyRootId({ + threadRootId, + replyToId: payload.replyToId, + }), }); } } diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index 6d8a3d5323206..6e039333c58de 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -230,6 +230,46 @@ describe("applyReplyThreading auto-threading", () => { expect(result[0].replyToId).toBe("42"); expect(result[0].replyToTag).toBe(true); }); + + it("resolves [[reply_to_current]] to currentMessageId when replyToMode is 'all'", () => { + // Mattermost-style scenario: agent responds with [[reply_to_current]] and replyToMode + // is "all". The tag should resolve to the inbound message id. + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]] some reply text" }], + replyToMode: "all", + currentMessageId: "mm-post-abc123", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("mm-post-abc123"); + expect(result[0].replyToTag).toBe(true); + expect(result[0].text).toBe("some reply text"); + }); + + it("resolves [[reply_to:]] to explicit id when replyToMode is 'all'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to:mm-post-xyz789]] threaded reply" }], + replyToMode: "all", + currentMessageId: "mm-post-abc123", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("mm-post-xyz789"); + expect(result[0].text).toBe("threaded reply"); + }); + + it("sets replyToId via implicit threading when replyToMode is 'all'", () => { + // Even without explicit tags, replyToMode "all" should set replyToId + // to currentMessageId for threading. + const result = applyReplyThreading({ + payloads: [{ text: "hello" }], + replyToMode: "all", + currentMessageId: "mm-post-abc123", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("mm-post-abc123"); + }); }); const baseRun: SubagentRunRecord = { From c1b914026d3f5601e82e119dbea0be0f45c3bcd3 Mon Sep 17 00:00:00 2001 From: "Nutchanon (Ben) Ninyawee" Date: Sun, 8 Mar 2026 16:00:17 +0700 Subject: [PATCH 006/311] fix: add missing strip-ansi dep for pi-coding-agent (#38999) Merged via squash. Prepared head SHA: dd03a6aaaf78705106681387faa60ba87901480e Co-authored-by: ninyawee <8089231+ninyawee@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- package.json | 9 ++++++++- pnpm-lock.yaml | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c3ab0aaaa2e1..cb89a3b99071a 100644 --- a/package.json +++ b/package.json @@ -443,6 +443,13 @@ "node-llama-cpp", "protobufjs", "sharp" - ] + ], + "packageExtensions": { + "@mariozechner/pi-coding-agent": { + "dependencies": { + "strip-ansi": "^7.2.0" + } + } + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc08b9f122099..b292763214563 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,8 @@ overrides: tar: 7.5.10 tough-cookie: 4.1.3 +packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8= + importers: .: @@ -8368,6 +8370,7 @@ snapshots: marked: 15.0.12 minimatch: 10.2.4 proper-lockfile: 4.1.2 + strip-ansi: 7.2.0 yaml: 2.8.2 optionalDependencies: '@mariozechner/clipboard': 0.3.2 From f73778e9b2cc2c1621497292453a3af8f5fb5b55 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 8 Mar 2026 12:04:46 +0300 Subject: [PATCH 007/311] fix: remove redundant root strip-ansi dependency (#39652) --- package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/package.json b/package.json index cb89a3b99071a..5b1e77c684d80 100644 --- a/package.json +++ b/package.json @@ -378,7 +378,6 @@ "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "sqlite-vec": "0.1.7-alpha.2", - "strip-ansi": "^7.2.0", "tar": "7.5.10", "tslog": "^4.10.2", "undici": "^7.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b292763214563..11144b4fd7717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,9 +168,6 @@ importers: sqlite-vec: specifier: 0.1.7-alpha.2 version: 0.1.7-alpha.2 - strip-ansi: - specifier: ^7.2.0 - version: 7.2.0 tar: specifier: 7.5.10 version: 7.5.10 From e5fdfec9dc1fee5a33775a10f47dc4ff90246737 Mon Sep 17 00:00:00 2001 From: gambletan Date: Sun, 8 Mar 2026 17:04:49 +0800 Subject: [PATCH 008/311] fix(config): accept "openclaw" as browser profile driver in Zod schema (#39374) Merged via squash. Prepared head SHA: 0eba5ab939e8074e44fe45dcb76e1a021d74156b Co-authored-by: gambletan <266203672+gambletan@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/config/schema.help.quality.test.ts | 2 +- src/config/schema.help.ts | 2 +- src/config/types.browser.ts | 2 +- src/config/zod-schema.ts | 4 +++- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc52f4a82738b..611a57d878834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -363,6 +363,7 @@ Docs: https://docs.openclaw.ai - ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps. - Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus. - Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee. +- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh. ## 2026.3.2 diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index f660af8831edd..1f0443d36a1bc 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -413,7 +413,7 @@ const ENUM_EXPECTATIONS: Record = { "gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'], "gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'], "gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'], - "browser.profiles.*.driver": ['"clawd"', '"extension"'], + "browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"extension"'], "discovery.mdns.mode": ['"off"', '"minimal"', '"full"'], "wizard.lastRunMode": ['"local"', '"remote"'], "diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'], diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f0d30c854e752..7d04ab5a93a37 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -255,7 +255,7 @@ export const FIELD_HELP: Record = { "browser.profiles.*.cdpUrl": "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", "browser.profiles.*.driver": - 'Per-profile browser driver mode: "clawd" or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.', + 'Per-profile browser driver mode: "openclaw" (or legacy "clawd") or "extension" depending on connection/runtime strategy. Use the driver that matches your browser control stack to avoid protocol mismatches.', "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color": diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 82a404037c417..192fd700bfff9 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -4,7 +4,7 @@ export type BrowserProfileConfig = { /** CDP URL for this profile (use for remote Chrome). */ cdpUrl?: string; /** Profile driver (default: openclaw). */ - driver?: "openclaw" | "extension"; + driver?: "openclaw" | "clawd" | "extension"; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ attachOnly?: boolean; /** Profile color (hex). Auto-assigned at creation. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 5148704a1ac14..a6c2b451b9aee 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -315,7 +315,9 @@ export const OpenClawSchema = z .object({ cdpPort: z.number().int().min(1).max(65535).optional(), cdpUrl: z.string().optional(), - driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(), + driver: z + .union([z.literal("openclaw"), z.literal("clawd"), z.literal("extension")]) + .optional(), attachOnly: z.boolean().optional(), color: HexColorSchema, }) From b38f37163030ebe1692500a48eac237de647cc56 Mon Sep 17 00:00:00 2001 From: "J. Campbell" Date: Sun, 8 Mar 2026 04:07:10 -0500 Subject: [PATCH 009/311] fix: add @tloncorp/api to pnpm onlyBuiltDependencies allowlist (#39027) Merged via squash. Prepared head SHA: e14935026096f34b99a715a88ddb02d97909eb50 Co-authored-by: apexfork <363026+apexfork@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- package.json | 1 + pnpm-workspace.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/package.json b/package.json index 5b1e77c684d80..f50f3b5fb2fc2 100644 --- a/package.json +++ b/package.json @@ -435,6 +435,7 @@ "@lydell/node-pty", "@matrix-org/matrix-sdk-crypto-nodejs", "@napi-rs/canvas", + "@tloncorp/api", "@whiskeysockets/baileys", "authenticate-pam", "esbuild", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7554c6494d952..b708dca457845 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ onlyBuiltDependencies: - "@lydell/node-pty" - "@matrix-org/matrix-sdk-crypto-nodejs" - "@napi-rs/canvas" + - "@tloncorp/api" - "@whiskeysockets/baileys" - authenticate-pam - esbuild From aedf3ee68f510af24b87fedf4fa3cf10848a6ca6 Mon Sep 17 00:00:00 2001 From: Farhoud Cheraghi Date: Sun, 8 Mar 2026 10:13:00 +0100 Subject: [PATCH 010/311] fix(skills): expand skill-creator description to cover edit/audit/review triggers (#39158) Merged via squash. Prepared head SHA: 13997c1ee5874d2c52e2383104db11dd5f50bb65 Co-authored-by: haynzz <1236319+haynzz@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- skills/skill-creator/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md index 369440fdba8dd..ad1e2c147fb4d 100644 --- a/skills/skill-creator/SKILL.md +++ b/skills/skill-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-creator -description: Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets. +description: Create, edit, improve, or audit AgentSkills. Use when creating a new skill from scratch or when asked to improve, review, audit, tidy up, or clean up an existing skill or SKILL.md file. Also use when editing or restructuring a skill directory (moving files to references/ or scripts/, removing stale content, validating against the AgentSkills spec). Triggers on phrases like "create a skill", "author a skill", "tidy up a skill", "improve this skill", "review the skill", "clean up the skill", "audit the skill". --- # Skill Creator From 8a20f5146047d66c2d384c6a5d5fb646fbcc8249 Mon Sep 17 00:00:00 2001 From: gambletan Date: Sun, 8 Mar 2026 18:03:33 +0800 Subject: [PATCH 011/311] fix: add rate limit patterns for 'too many tokens' and 'tokens per day' (#39377) Merged via squash. Prepared head SHA: 132a45728694053c0e3220e7d861508524f17244 Co-authored-by: gambletan <266203672+gambletan@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- ...dded-helpers.isbillingerrormessage.test.ts | 12 ++++ src/agents/pi-embedded-helpers/errors.ts | 2 +- .../pi-embedded-helpers/failover-matches.ts | 1 + src/cron/service.issue-regressions.test.ts | 55 +++++++++++++++++++ src/cron/service/timer.ts | 3 +- src/memory/manager-embedding-ops.ts | 2 +- src/memory/manager.embedding-batches.test.ts | 26 +++++++++ 7 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 8649f46f8712b..86fd90e71614c 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -416,12 +416,19 @@ describe("isLikelyContextOverflowError", () => { "exceeded your current quota", "This request would exceed your account's rate limit", "429 Too Many Requests: request exceeds rate limit", + "AWS Bedrock: Too many tokens per day. Please try again tomorrow.", ]; for (const sample of samples) { expect(isLikelyContextOverflowError(sample)).toBe(false); } }); + it("keeps too-many-tokens-per-request context overflow errors out of the rate-limit lane", () => { + const sample = "Context window exceeded: too many tokens per request."; + expect(isLikelyContextOverflowError(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBeNull(); + }); + it("excludes reasoning-required invalid-request errors", () => { const samples = [ "400 Reasoning is mandatory for this endpoint and cannot be disabled.", @@ -654,6 +661,11 @@ describe("classifyFailoverReason", () => { "rate_limit", ); }); + it("classifies AWS Bedrock too-many-tokens-per-day errors as rate_limit", () => { + expect( + classifyFailoverReason("AWS Bedrock: Too many tokens per day. Please try again tomorrow."), + ).toBe("rate_limit"); + }); it("classifies provider high-demand / service-unavailable messages as overloaded", () => { expect( classifyFailoverReason( diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index cd4701c9db916..4cf347150bf1b 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -122,7 +122,7 @@ const CONTEXT_WINDOW_TOO_SMALL_RE = /context window.*(too small|minimum is)/i; const CONTEXT_OVERFLOW_HINT_RE = /context.*overflow|context window.*(too (?:large|long)|exceed|over|limit|max(?:imum)?|requested|sent|tokens)|prompt.*(too (?:large|long)|exceed|over|limit|max(?:imum)?)|(?:request|input).*(?:context|window|length|token).*(too (?:large|long)|exceed|over|limit|max(?:imum)?)/i; const RATE_LIMIT_HINT_RE = - /rate limit|too many requests|requests per (?:minute|hour|day)|quota|throttl|429\b/i; + /rate limit|too many requests|requests per (?:minute|hour|day)|quota|throttl|429\b|tokens per day/i; export function isLikelyContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) { diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 6a7ce9d51d383..f2e0e3870ab3c 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -14,6 +14,7 @@ const ERROR_PATTERNS = { "usage limit", /\btpm\b/i, "tokens per minute", + "tokens per day", ], overloaded: [ /overloaded_error|"type"\s*:\s*"overloaded_error"/i, diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 9aec71b731557..54f341bc7eaa2 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -800,6 +800,61 @@ describe("Cron issue regressions", () => { expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2); }); + it("#38822: one-shot job retries Bedrock too-many-tokens-per-day errors", async () => { + const store = makeStorePath(); + const scheduledAt = Date.parse("2026-03-08T10:00:00.000Z"); + + const cronJob = createIsolatedRegressionJob({ + id: "oneshot-bedrock-too-many-tokens-per-day", + name: "reminder", + scheduledAt, + schedule: { kind: "at", at: new Date(scheduledAt).toISOString() }, + payload: { kind: "agentTurn", message: "remind me" }, + state: { nextRunAtMs: scheduledAt }, + }); + await writeCronJobs(store.storePath, [cronJob]); + + let now = scheduledAt; + const runIsolatedAgentJob = vi + .fn() + .mockResolvedValueOnce({ + status: "error", + error: "AWS Bedrock: Too many tokens per day. Please try again tomorrow.", + }) + .mockResolvedValueOnce({ status: "ok", summary: "done" }); + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + cronConfig: { + retry: { maxAttempts: 1, backoffMs: [1000], retryOn: ["rate_limit"] }, + }, + }); + + await onTimer(state); + const jobAfterRetry = state.store?.jobs.find( + (j) => j.id === "oneshot-bedrock-too-many-tokens-per-day", + ); + expect(jobAfterRetry).toBeDefined(); + expect(jobAfterRetry!.enabled).toBe(true); + expect(jobAfterRetry!.state.lastStatus).toBe("error"); + expect(jobAfterRetry!.state.nextRunAtMs).toBeGreaterThan(scheduledAt); + + now = (jobAfterRetry!.state.nextRunAtMs ?? now) + 1; + await onTimer(state); + + const finishedJob = state.store?.jobs.find( + (j) => j.id === "oneshot-bedrock-too-many-tokens-per-day", + ); + expect(finishedJob).toBeDefined(); + expect(finishedJob!.state.lastStatus).toBe("ok"); + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2); + }); + it("#24355: one-shot job disabled immediately on permanent error", async () => { const store = makeStorePath(); const scheduledAt = Date.parse("2026-02-06T10:00:00.000Z"); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 8502f3b6fe8a7..3f50ca757e886 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -119,7 +119,8 @@ function errorBackoffMs( const DEFAULT_MAX_TRANSIENT_RETRIES = 3; const TRANSIENT_PATTERNS: Record = { - rate_limit: /(rate[_ ]limit|too many requests|429|resource has been exhausted|cloudflare)/i, + rate_limit: + /(rate[_ ]limit|too many requests|429|resource has been exhausted|cloudflare|tokens per day)/i, overloaded: /\b529\b|\boverloaded(?:_error)?\b|high demand|temporar(?:ily|y) overloaded|capacity exceeded/i, network: /(network|econnreset|econnrefused|fetch failed|socket)/i, diff --git a/src/memory/manager-embedding-ops.ts b/src/memory/manager-embedding-ops.ts index 6da8b7ffa3b0f..965058c8a3b97 100644 --- a/src/memory/manager-embedding-ops.ts +++ b/src/memory/manager-embedding-ops.ts @@ -532,7 +532,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { } private isRetryableEmbeddingError(message: string): boolean { - return /(rate[_ ]limit|too many requests|429|resource has been exhausted|5\d\d|cloudflare)/i.test( + return /(rate[_ ]limit|too many requests|429|resource has been exhausted|5\d\d|cloudflare|tokens per day)/i.test( message, ); } diff --git a/src/memory/manager.embedding-batches.test.ts b/src/memory/manager.embedding-batches.test.ts index 1326eca71eb0a..1d81744f2801d 100644 --- a/src/memory/manager.embedding-batches.test.ts +++ b/src/memory/manager.embedding-batches.test.ts @@ -103,6 +103,32 @@ describe("memory embedding batches", () => { expect(calls).toBe(3); }, 10000); + it("retries embeddings on too-many-tokens-per-day rate limits", async () => { + const memoryDir = fx.getMemoryDir(); + const managerSmall = fx.getManagerSmall(); + const line = "e".repeat(120); + const content = Array.from({ length: 4 }, () => line).join("\n"); + await fs.writeFile(path.join(memoryDir, "2026-01-08.md"), content); + + let calls = 0; + embedBatch.mockImplementation(async (texts: string[]) => { + calls += 1; + if (calls === 1) { + throw new Error("AWS Bedrock embeddings failed: Too many tokens per day"); + } + return texts.map(() => [0, 1, 0]); + }); + + const restoreFastTimeouts = useFastShortTimeouts(); + try { + await managerSmall.sync({ reason: "test" }); + } finally { + restoreFastTimeouts(); + } + + expect(calls).toBe(2); + }, 10000); + it("skips empty chunks so embeddings input stays valid", async () => { const memoryDir = fx.getMemoryDir(); const managerSmall = fx.getManagerSmall(); From f4c4856254fe3581f2cd533133dffa8a1f43a506 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 8 Mar 2026 13:09:26 +0300 Subject: [PATCH 012/311] docs(changelog): add #39377 failover note (#39704) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 611a57d878834..9d880be83dae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. +- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. ## 2026.3.7 From 492fe679a703a0809be1575c65183af02a0a4182 Mon Sep 17 00:00:00 2001 From: arceus77-7 Date: Sun, 8 Mar 2026 06:31:11 -0400 Subject: [PATCH 013/311] feat(tui): infer workspace agent when launching TUI (#39591) Merged via squash. Prepared head SHA: 23533e24c4cbb91fd5c3006485c9a5d69bd5c2fb Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 2 + docs/cli/tui.md | 3 ++ src/agents/agent-scope.test.ts | 93 ++++++++++++++++++++++++++++++++++ src/agents/agent-scope.ts | 57 +++++++++++++++++++++ src/tui/tui.test.ts | 46 +++++++++++++++++ src/tui/tui.ts | 33 ++++++++++-- 6 files changed, 231 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d880be83dae5..f87d5006da670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. + ### Fixes - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. diff --git a/docs/cli/tui.md b/docs/cli/tui.md index de84ae08d89a7..f289cfbe9b219 100644 --- a/docs/cli/tui.md +++ b/docs/cli/tui.md @@ -17,6 +17,7 @@ Related: Notes: - `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers). +- When launched from inside a configured agent workspace directory, TUI auto-selects that agent for the session key default (unless `--session` is explicitly `agent::...`). ## Examples @@ -24,4 +25,6 @@ Notes: openclaw tui openclaw tui --url ws://127.0.0.1:18789 --token openclaw tui --session main --deliver +# when run inside an agent workspace, infers that agent automatically +openclaw tui --session bugfix ``` diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index ad4e0f56fd025..8c25f2baf97de 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -13,6 +15,8 @@ import { resolveAgentModelPrimary, resolveRunModelFallbacksOverride, resolveAgentWorkspaceDir, + resolveAgentIdByWorkspacePath, + resolveAgentIdsByWorkspacePath, } from "./agent-scope.js"; afterEach(() => { @@ -428,3 +432,92 @@ describe("resolveAgentConfig", () => { expect(agentDir).toBe(path.join(path.resolve(home), ".openclaw", "agents", "main", "agent")); }); }); + +describe("resolveAgentIdByWorkspacePath", () => { + it("returns the most specific workspace match for a directory", () => { + const workspaceRoot = `/tmp/openclaw-agent-scope-${Date.now()}-root`; + const opsWorkspace = `${workspaceRoot}/projects/ops`; + const cfg: OpenClawConfig = { + agents: { + list: [ + { id: "main", workspace: workspaceRoot }, + { id: "ops", workspace: opsWorkspace }, + ], + }, + }; + + expect(resolveAgentIdByWorkspacePath(cfg, `${opsWorkspace}/src`)).toBe("ops"); + }); + + it("returns undefined when directory has no matching workspace", () => { + const workspaceRoot = `/tmp/openclaw-agent-scope-${Date.now()}-root`; + const cfg: OpenClawConfig = { + agents: { + list: [ + { id: "main", workspace: workspaceRoot }, + { id: "ops", workspace: `${workspaceRoot}-ops` }, + ], + }, + }; + + expect( + resolveAgentIdByWorkspacePath(cfg, `/tmp/openclaw-agent-scope-${Date.now()}-unrelated`), + ).toBeUndefined(); + }); + + it("matches workspace paths through symlink aliases", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-scope-")); + const realWorkspaceRoot = path.join(tempRoot, "real-root"); + const realOpsWorkspace = path.join(realWorkspaceRoot, "projects", "ops"); + const aliasWorkspaceRoot = path.join(tempRoot, "alias-root"); + try { + fs.mkdirSync(path.join(realOpsWorkspace, "src"), { recursive: true }); + fs.symlinkSync( + realWorkspaceRoot, + aliasWorkspaceRoot, + process.platform === "win32" ? "junction" : "dir", + ); + + const cfg: OpenClawConfig = { + agents: { + list: [ + { id: "main", workspace: realWorkspaceRoot }, + { id: "ops", workspace: realOpsWorkspace }, + ], + }, + }; + + expect( + resolveAgentIdByWorkspacePath(cfg, path.join(aliasWorkspaceRoot, "projects", "ops")), + ).toBe("ops"); + expect( + resolveAgentIdByWorkspacePath(cfg, path.join(aliasWorkspaceRoot, "projects", "ops", "src")), + ).toBe("ops"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + +describe("resolveAgentIdsByWorkspacePath", () => { + it("returns matching workspaces ordered by specificity", () => { + const workspaceRoot = `/tmp/openclaw-agent-scope-${Date.now()}-root`; + const opsWorkspace = `${workspaceRoot}/projects/ops`; + const opsDevWorkspace = `${opsWorkspace}/dev`; + const cfg: OpenClawConfig = { + agents: { + list: [ + { id: "main", workspace: workspaceRoot }, + { id: "ops", workspace: opsWorkspace }, + { id: "ops-dev", workspace: opsDevWorkspace }, + ], + }, + }; + + expect(resolveAgentIdsByWorkspacePath(cfg, `${opsDevWorkspace}/pkg`)).toEqual([ + "ops-dev", + "ops", + "main", + ]); + }); +}); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index bdc8806569696..5d190ce1eae0b 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelFallbackValues } from "../config/model-input.js"; @@ -270,6 +271,62 @@ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { return stripNullBytes(path.join(stateDir, `workspace-${id}`)); } +function normalizePathForComparison(input: string): string { + const resolved = path.resolve(stripNullBytes(resolveUserPath(input))); + let normalized = resolved; + // Prefer realpath when available to normalize aliases/symlinks (for example /tmp -> /private/tmp) + // and canonical path case without forcing case-folding on case-sensitive macOS volumes. + try { + normalized = fs.realpathSync.native(resolved); + } catch { + // Keep lexical path for non-existent directories. + } + if (process.platform === "win32") { + return normalized.toLowerCase(); + } + return normalized; +} + +function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function resolveAgentIdsByWorkspacePath( + cfg: OpenClawConfig, + workspacePath: string, +): string[] { + const normalizedWorkspacePath = normalizePathForComparison(workspacePath); + const ids = listAgentIds(cfg); + const matches: Array<{ id: string; workspaceDir: string; order: number }> = []; + + for (let index = 0; index < ids.length; index += 1) { + const id = ids[index]; + const workspaceDir = normalizePathForComparison(resolveAgentWorkspaceDir(cfg, id)); + if (!isPathWithinRoot(normalizedWorkspacePath, workspaceDir)) { + continue; + } + matches.push({ id, workspaceDir, order: index }); + } + + matches.sort((left, right) => { + const workspaceLengthDelta = right.workspaceDir.length - left.workspaceDir.length; + if (workspaceLengthDelta !== 0) { + return workspaceLengthDelta; + } + return left.order - right.order; + }); + + return matches.map((entry) => entry.id); +} + +export function resolveAgentIdByWorkspacePath( + cfg: OpenClawConfig, + workspacePath: string, +): string | undefined { + return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0]; +} + export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim(); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 14a11c4591d04..773c03f6de348 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { getSlashCommands, parseCommand } from "./commands.js"; import { createBackspaceDeduper, @@ -6,6 +7,7 @@ import { resolveCtrlCAction, resolveFinalAssistantText, resolveGatewayDisconnectState, + resolveInitialTuiAgentId, resolveTuiSessionKey, stopTuiSafely, } from "./tui.js"; @@ -107,6 +109,50 @@ describe("resolveTuiSessionKey", () => { }); }); +describe("resolveInitialTuiAgentId", () => { + const cfg: OpenClawConfig = { + agents: { + list: [ + { id: "main", workspace: "/tmp/openclaw" }, + { id: "ops", workspace: "/tmp/openclaw/projects/ops" }, + ], + }, + }; + + it("infers agent from cwd when session is not agent-prefixed", () => { + expect( + resolveInitialTuiAgentId({ + cfg, + fallbackAgentId: "main", + initialSessionInput: "", + cwd: "/tmp/openclaw/projects/ops/src", + }), + ).toBe("ops"); + }); + + it("keeps explicit agent prefix from --session", () => { + expect( + resolveInitialTuiAgentId({ + cfg, + fallbackAgentId: "main", + initialSessionInput: "agent:main:incident", + cwd: "/tmp/openclaw/projects/ops/src", + }), + ).toBe("main"); + }); + + it("falls back when cwd has no matching workspace", () => { + expect( + resolveInitialTuiAgentId({ + cfg, + fallbackAgentId: "main", + initialSessionInput: "", + cwd: "/var/tmp/unrelated", + }), + ).toBe("main"); + }); +}); + describe("resolveGatewayDisconnectState", () => { it("returns pairing recovery guidance when disconnect reason requires pairing", () => { const state = resolveGatewayDisconnectState("gateway closed (1008): pairing required"); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 0dd24a95ac3d9..28ea21d85fb13 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -8,8 +8,8 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig } from "../config/config.js"; +import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId, @@ -208,6 +208,28 @@ export function resolveTuiSessionKey(params: { return `agent:${params.currentAgentId}:${trimmed.toLowerCase()}`; } +export function resolveInitialTuiAgentId(params: { + cfg: OpenClawConfig; + fallbackAgentId: string; + initialSessionInput?: string; + cwd?: string; +}) { + const parsed = parseAgentSessionKey((params.initialSessionInput ?? "").trim()); + if (parsed?.agentId) { + return normalizeAgentId(parsed.agentId); + } + + const inferredFromWorkspace = resolveAgentIdByWorkspacePath( + params.cfg, + params.cwd ?? process.cwd(), + ); + if (inferredFromWorkspace) { + return inferredFromWorkspace; + } + + return normalizeAgentId(params.fallbackAgentId); +} + export function resolveGatewayDisconnectState(reason?: string): { connectionStatus: string; activityStatus: string; @@ -303,7 +325,12 @@ export async function runTui(opts: TuiOptions) { let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; let sessionMainKey = normalizeMainKey(config.session?.mainKey); let agentDefaultId = resolveDefaultAgentId(config); - let currentAgentId = agentDefaultId; + let currentAgentId = resolveInitialTuiAgentId({ + cfg: config, + fallbackAgentId: agentDefaultId, + initialSessionInput, + cwd: process.cwd(), + }); let agents: AgentSummary[] = []; const agentNames = new Map(); let currentSessionKey = ""; From 0f9566b0b51249006f9a183b0b78bde206b707f9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 14:46:56 +0530 Subject: [PATCH 014/311] fix(android): remove self-update install flow --- apps/android/README.md | 2 +- apps/android/app/src/main/AndroidManifest.xml | 5 - .../ai/openclaw/app/InstallResultReceiver.kt | 33 -- .../main/java/ai/openclaw/app/NodeRuntime.kt | 6 - .../ai/openclaw/app/node/AppUpdateHandler.kt | 295 ------------------ .../app/node/InvokeCommandRegistry.kt | 2 - .../ai/openclaw/app/node/InvokeDispatcher.kt | 5 - .../app/protocol/OpenClawProtocolConstants.kt | 1 - .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 54 ---- .../java/ai/openclaw/app/ui/SettingsSheet.kt | 60 ---- .../openclaw/app/node/AppUpdateHandlerTest.kt | 65 ---- .../app/node/InvokeCommandRegistryTest.kt | 2 - .../protocol/OpenClawProtocolConstantsTest.kt | 1 - docs/concepts/features.md | 2 +- docs/nodes/index.md | 2 - docs/platforms/android.md | 1 - .../android-node.capabilities.live.test.ts | 6 - 17 files changed, 2 insertions(+), 540 deletions(-) delete mode 100644 apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt delete mode 100644 apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt delete mode 100644 apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt diff --git a/apps/android/README.md b/apps/android/README.md index 50704e63d0b1e..0a92e4c8ec55d 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -211,7 +211,7 @@ What it does: - Reads `node.describe` command list from the selected Android node. - Invokes advertised non-interactive commands. - Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent). -- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`). +- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`). Common failure quick-fixes: diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 0507bdf8aa1f0..591627eca3fc8 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,6 @@ - @@ -76,9 +75,5 @@ - - diff --git a/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt b/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt deleted file mode 100644 index 745ea11f96ead..0000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/InstallResultReceiver.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ai.openclaw.app - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageInstaller -import android.util.Log - -class InstallResultReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) - val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - - when (status) { - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - // System needs user confirmation — launch the confirmation activity - @Suppress("DEPRECATION") - val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) - if (confirmIntent != null) { - confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(confirmIntent) - Log.w("openclaw", "app.update: user confirmation requested, launching install dialog") - } - } - PackageInstaller.STATUS_SUCCESS -> { - Log.w("openclaw", "app.update: install SUCCESS") - } - else -> { - Log.e("openclaw", "app.update: install FAILED status=$status message=$message") - } - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 263a80fc0767d..fc821f9fa2e3c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -77,11 +77,6 @@ class NodeRuntime(context: Context) { identityStore = identityStore, ) - private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler( - appContext = appContext, - connectedEndpoint = { connectedEndpoint }, - ) - private val locationHandler: LocationHandler = LocationHandler( appContext = appContext, location = location, @@ -163,7 +158,6 @@ class NodeRuntime(context: Context) { smsHandler = smsHandlerImpl, a2uiHandler = a2uiHandler, debugHandler = debugHandler, - appUpdateHandler = appUpdateHandler, isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt deleted file mode 100644 index f314d3330dc5f..0000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/AppUpdateHandler.kt +++ /dev/null @@ -1,295 +0,0 @@ -package ai.openclaw.app.node - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import ai.openclaw.app.InstallResultReceiver -import ai.openclaw.app.MainActivity -import ai.openclaw.app.gateway.GatewayEndpoint -import ai.openclaw.app.gateway.GatewaySession -import java.io.File -import java.net.URI -import java.security.MessageDigest -import java.util.Locale -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$") - -internal data class AppUpdateRequest( - val url: String, - val expectedSha256: String, -) - -internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest { - val params = - try { - paramsJson?.let { Json.parseToJsonElement(it).jsonObject } - } catch (_: Throwable) { - throw IllegalArgumentException("params must be valid JSON") - } ?: throw IllegalArgumentException("missing 'url' parameter") - - val urlRaw = - params["url"]?.jsonPrimitive?.content?.trim().orEmpty() - .ifEmpty { throw IllegalArgumentException("missing 'url' parameter") } - val sha256Raw = - params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty() - .ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") } - if (!SHA256_HEX.matches(sha256Raw)) { - throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)") - } - - val uri = - try { - URI(urlRaw) - } catch (_: Throwable) { - throw IllegalArgumentException("invalid 'url' parameter") - } - val scheme = uri.scheme?.lowercase(Locale.US).orEmpty() - if (scheme != "https") { - throw IllegalArgumentException("url must use https") - } - if (!uri.userInfo.isNullOrBlank()) { - throw IllegalArgumentException("url must not include credentials") - } - val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required") - val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty() - if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) { - throw IllegalArgumentException("url host must match connected gateway host") - } - - return AppUpdateRequest( - url = uri.toASCIIString(), - expectedSha256 = sha256Raw.lowercase(Locale.US), - ) -} - -internal fun sha256Hex(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - if (read == 0) continue - digest.update(buffer, 0, read) - } - } - val out = StringBuilder(64) - for (byte in digest.digest()) { - out.append(String.format(Locale.US, "%02x", byte)) - } - return out.toString() -} - -class AppUpdateHandler( - private val appContext: Context, - private val connectedEndpoint: () -> GatewayEndpoint?, -) { - - fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult { - try { - val updateRequest = - try { - parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host) - } catch (err: IllegalArgumentException) { - return GatewaySession.InvokeResult.error( - code = "INVALID_REQUEST", - message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}", - ) - } - val url = updateRequest.url - val expectedSha256 = updateRequest.expectedSha256 - - android.util.Log.w("openclaw", "app.update: downloading from $url") - - val notifId = 9001 - val channelId = "app_update" - val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager - - // Create notification channel (required for Android 8+) - val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW) - notifManager.createNotificationChannel(channel) - - // PendingIntent to open the app when notification is tapped - val launchIntent = Intent(appContext, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - - // Launch download async so the invoke returns immediately - CoroutineScope(Dispatchers.IO).launch { - try { - val cacheDir = java.io.File(appContext.cacheDir, "updates") - cacheDir.mkdirs() - val file = java.io.File(cacheDir, "update.apk") - if (file.exists()) file.delete() - - // Show initial progress notification - fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification { - return android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentTitle("OpenClaw Update") - .setContentText(text) - .setProgress(max, progress, max == 0) - - .setContentIntent(launchPi) - .setOngoing(true) - .build() - } - notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting...")) - - val client = okhttp3.OkHttpClient.Builder() - .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(300, java.util.concurrent.TimeUnit.SECONDS) - .build() - val request = okhttp3.Request.Builder().url(url).build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - notifManager.cancel(notifId) - notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - - .setContentIntent(launchPi) - .setContentText("HTTP ${response.code}") - .build()) - return@launch - } - - val contentLength = response.body?.contentLength() ?: -1L - val body = response.body ?: run { - notifManager.cancel(notifId) - return@launch - } - - // Download with progress tracking - var totalBytes = 0L - var lastNotifUpdate = 0L - body.byteStream().use { input -> - file.outputStream().use { output -> - val buffer = ByteArray(8192) - while (true) { - val bytesRead = input.read(buffer) - if (bytesRead == -1) break - output.write(buffer, 0, bytesRead) - totalBytes += bytesRead - - // Update notification at most every 500ms - val now = System.currentTimeMillis() - if (now - lastNotifUpdate > 500) { - lastNotifUpdate = now - if (contentLength > 0) { - val pct = ((totalBytes * 100) / contentLength).toInt() - val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0) - val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0) - notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)")) - } else { - val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0) - notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded")) - } - } - } - } - } - - android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes") - val actualSha256 = sha256Hex(file) - if (actualSha256 != expectedSha256) { - android.util.Log.e( - "openclaw", - "app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256", - ) - file.delete() - notifManager.cancel(notifId) - notifManager.notify( - notifId, - android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - .setContentIntent(launchPi) - .setContentText("SHA-256 mismatch") - .build(), - ) - return@launch - } - - // Verify file is a valid APK (basic check: ZIP magic bytes) - val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() } - if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) { - android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})") - file.delete() - notifManager.cancel(notifId) - notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - - .setContentIntent(launchPi) - .setContentText("Downloaded file is not a valid APK") - .build()) - return@launch - } - - // Use PackageInstaller session API — works from background on API 34+ - // The system handles showing the install confirmation dialog - notifManager.cancel(notifId) - notifManager.notify( - notifId, - android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setContentTitle("Installing Update...") - .setContentIntent(launchPi) - .setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded") - .build(), - ) - - val installer = appContext.packageManager.packageInstaller - val params = android.content.pm.PackageInstaller.SessionParams( - android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL - ) - params.setSize(file.length()) - val sessionId = installer.createSession(params) - val session = installer.openSession(sessionId) - session.openWrite("openclaw-update.apk", 0, file.length()).use { out -> - file.inputStream().use { inp -> inp.copyTo(out) } - session.fsync(out) - } - // Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status - val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java) - val pi = android.app.PendingIntent.getBroadcast( - appContext, sessionId, callbackIntent, - android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE - ) - session.commit(pi.intentSender) - android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation") - } catch (err: Throwable) { - android.util.Log.e("openclaw", "app.update: async error", err) - notifManager.cancel(notifId) - notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setContentTitle("Update Failed") - - .setContentIntent(launchPi) - .setContentText(err.message ?: "Unknown error") - .build()) - } - } - - // Return immediately — download happens in background - return GatewaySession.InvokeResult.ok(buildJsonObject { - put("status", "downloading") - put("url", url) - put("sha256", expectedSha256) - }.toString()) - } catch (err: Throwable) { - android.util.Log.e("openclaw", "app.update: error", err) - return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed") - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt index 9f7ee1a890ad4..adb9b6030bf9f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt @@ -63,7 +63,6 @@ object InvokeCommandRegistry { NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue), NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue), NodeCapabilitySpec(name = OpenClawCapability.System.rawValue), - NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue), NodeCapabilitySpec( name = OpenClawCapability.Camera.rawValue, availability = NodeCapabilityAvailability.CameraEnabled, @@ -202,7 +201,6 @@ object InvokeCommandRegistry { name = "debug.ed25519", availability = InvokeCommandAvailability.DebugBuild, ), - InvokeCommandSpec(name = "app.update"), ) private val byNameInternal: Map = all.associateBy { it.name } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index dc6eed7438d90..0e3fe458a7f6c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -29,7 +29,6 @@ class InvokeDispatcher( private val smsHandler: SmsHandler, private val a2uiHandler: A2UIHandler, private val debugHandler: DebugHandler, - private val appUpdateHandler: AppUpdateHandler, private val isForeground: () -> Boolean, private val cameraEnabled: () -> Boolean, private val locationEnabled: () -> Boolean, @@ -170,10 +169,6 @@ class InvokeDispatcher( // Debug commands "debug.ed25519" -> debugHandler.handleEd25519() "debug.logs" -> debugHandler.handleLogs() - - // App update - "app.update" -> appUpdateHandler.handleUpdate(paramsJson) - else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command") } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt index ef4c2d95c96f7..8c9a1f4c22012 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt @@ -10,7 +10,6 @@ enum class OpenClawCapability(val rawValue: String) { Device("device"), Notifications("notifications"), System("system"), - AppUpdate("appUpdate"), Photos("photos"), Contacts("contacts"), Calendar("calendar"), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 417abd34e5242..5db2a5e6d78fb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -80,7 +80,6 @@ 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 androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -118,7 +117,6 @@ private enum class PermissionToggle { private enum class SpecialAccessToggle { NotificationListener, - AppUpdates, } private val onboardingBackgroundGradient = @@ -274,10 +272,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { rememberSaveable { mutableStateOf(isNotificationListenerEnabled(context)) } - var enableAppUpdates by - rememberSaveable { - mutableStateOf(canInstallUnknownApps(context)) - } var enableMicrophone by rememberSaveable { mutableStateOf(false) } var enableCamera by rememberSaveable { mutableStateOf(false) } var enablePhotos by rememberSaveable { mutableStateOf(false) } @@ -342,7 +336,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) { when (toggle) { SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled - SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled } } @@ -352,7 +345,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { enableLocation, enableNotifications, enableNotificationListener, - enableAppUpdates, enableMicrophone, enableCamera, enablePhotos, @@ -368,7 +360,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { if (enableLocation) enabled += "Location" if (enableNotifications) enabled += "Notifications" if (enableNotificationListener) enabled += "Notification listener" - if (enableAppUpdates) enabled += "App updates" if (enableMicrophone) enabled += "Microphone" if (enableCamera) enabled += "Camera" if (enablePhotos) enabled += "Photos" @@ -385,10 +376,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { openNotificationListenerSettings(context) openedSpecialSetup = true } - if (enableAppUpdates && !canInstallUnknownApps(context)) { - openUnknownAppSourcesSettings(context) - openedSpecialSetup = true - } if (openedSpecialSetup) { return@proceed } @@ -431,7 +418,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { val grantedNow = when (toggle) { SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context) - SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context) } if (grantedNow) { setSpecialAccessToggleEnabled(toggle, true) @@ -441,7 +427,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { pendingSpecialAccessToggle = toggle when (toggle) { SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context) - SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context) } } @@ -459,13 +444,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { ) pendingSpecialAccessToggle = null } - SpecialAccessToggle.AppUpdates -> { - setSpecialAccessToggleEnabled( - SpecialAccessToggle.AppUpdates, - canInstallUnknownApps(context), - ) - pendingSpecialAccessToggle = null - } null -> Unit } } @@ -606,7 +584,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { enableLocation = enableLocation, enableNotifications = enableNotifications, enableNotificationListener = enableNotificationListener, - enableAppUpdates = enableAppUpdates, enableMicrophone = enableMicrophone, enableCamera = enableCamera, enablePhotos = enablePhotos, @@ -649,9 +626,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { onNotificationListenerChange = { checked -> requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked) }, - onAppUpdatesChange = { checked -> - requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked) - }, onMicrophoneChange = { checked -> requestPermissionToggle( PermissionToggle.Microphone, @@ -1337,7 +1311,6 @@ private fun PermissionsStep( enableLocation: Boolean, enableNotifications: Boolean, enableNotificationListener: Boolean, - enableAppUpdates: Boolean, enableMicrophone: Boolean, enableCamera: Boolean, enablePhotos: Boolean, @@ -1353,7 +1326,6 @@ private fun PermissionsStep( onLocationChange: (Boolean) -> Unit, onNotificationsChange: (Boolean) -> Unit, onNotificationListenerChange: (Boolean) -> Unit, - onAppUpdatesChange: (Boolean) -> Unit, onMicrophoneChange: (Boolean) -> Unit, onCameraChange: (Boolean) -> Unit, onPhotosChange: (Boolean) -> Unit, @@ -1387,7 +1359,6 @@ private fun PermissionsStep( isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION) } val notificationListenerGranted = isNotificationListenerEnabled(context) - val appUpdatesGranted = canInstallUnknownApps(context) StepShell(title = "Permissions") { Text( @@ -1429,14 +1400,6 @@ private fun PermissionsStep( onCheckedChange = onNotificationListenerChange, ) InlineDivider() - PermissionToggleRow( - title = "App updates", - subtitle = "app.update install confirmation (opens Android Settings)", - checked = enableAppUpdates, - granted = appUpdatesGranted, - onCheckedChange = onAppUpdatesChange, - ) - InlineDivider() PermissionToggleRow( title = "Microphone", subtitle = "Voice tab transcription", @@ -1635,10 +1598,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean { return DeviceNotificationListenerService.isAccessEnabled(context) } -private fun canInstallUnknownApps(context: Context): Boolean { - return context.packageManager.canRequestPackageInstalls() -} - private fun openNotificationListenerSettings(context: Context) { val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) runCatching { @@ -1648,19 +1607,6 @@ private fun openNotificationListenerSettings(context: Context) { } } -private fun openUnknownAppSourcesSettings(context: Context) { - val intent = - Intent( - Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - "package:${context.packageName}".toUri(), - ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - runCatching { - context.startActivity(intent) - }.getOrElse { - openAppSettings(context) - } -} - private fun openAppSettings(context: Context) { val intent = Intent( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index 1be0e23b63fa1..a58d66f8531c5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -62,7 +62,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -246,11 +245,6 @@ fun SettingsSheet(viewModel: MainViewModel) { motionPermissionGranted = granted } - var appUpdateInstallEnabled by - remember { - mutableStateOf(canInstallUnknownApps(context)) - } - var smsPermissionGranted by remember { mutableStateOf( @@ -290,7 +284,6 @@ fun SettingsSheet(viewModel: MainViewModel) { !motionPermissionRequired || ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED - appUpdateInstallEnabled = canInstallUnknownApps(context) smsPermissionGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED @@ -759,41 +752,6 @@ fun SettingsSheet(viewModel: MainViewModel) { } item { HorizontalDivider(color = mobileBorder) } - // System - item { - Text( - "SYSTEM", - style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp), - color = mobileAccent, - ) - } - item { - ListItem( - modifier = Modifier.settingsRowModifier(), - colors = listItemColors, - headlineContent = { Text("Install App Updates", style = mobileHeadline) }, - supportingContent = { - Text( - "Enable install access for `app.update` package installs.", - style = mobileCallout, - ) - }, - trailingContent = { - Button( - onClick = { openUnknownAppSourcesSettings(context) }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (appUpdateInstallEnabled) "Manage" else "Enable", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) - } - item { HorizontalDivider(color = mobileBorder) } - // Location item { Text( @@ -865,7 +823,6 @@ fun SettingsSheet(viewModel: MainViewModel) { color = mobileTextSecondary, ) } - item { HorizontalDivider(color = mobileBorder) } // Screen @@ -970,19 +927,6 @@ private fun openNotificationListenerSettings(context: Context) { } } -private fun openUnknownAppSourcesSettings(context: Context) { - val intent = - Intent( - Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, - "package:${context.packageName}".toUri(), - ) - runCatching { - context.startActivity(intent) - }.getOrElse { - openAppSettings(context) - } -} - private fun hasNotificationsPermission(context: Context): Boolean { if (Build.VERSION.SDK_INT < 33) return true return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == @@ -993,10 +937,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean { return DeviceNotificationListenerService.isAccessEnabled(context) } -private fun canInstallUnknownApps(context: Context): Boolean { - return context.packageManager.canRequestPackageInstalls() -} - private fun hasMotionCapabilities(context: Context): Boolean { val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt deleted file mode 100644 index e0bad8e1fd120..0000000000000 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package ai.openclaw.app.node - -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class AppUpdateHandlerTest { - @Test - fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() { - val req = - parseAppUpdateRequest( - paramsJson = - """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", - connectedHost = "gw.example.com", - ) - - assertEquals("https://gw.example.com/releases/openclaw.apk", req.url) - assertEquals("a".repeat(64), req.expectedSha256) - } - - @Test - fun parseAppUpdateRequest_rejectsNonHttps() { - assertThrows(IllegalArgumentException::class.java) { - parseAppUpdateRequest( - paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", - connectedHost = "gw.example.com", - ) - } - } - - @Test - fun parseAppUpdateRequest_rejectsHostMismatch() { - assertThrows(IllegalArgumentException::class.java) { - parseAppUpdateRequest( - paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""", - connectedHost = "gw.example.com", - ) - } - } - - @Test - fun parseAppUpdateRequest_rejectsInvalidSha256() { - assertThrows(IllegalArgumentException::class.java) { - parseAppUpdateRequest( - paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""", - connectedHost = "gw.example.com", - ) - } - } - - @Test - fun sha256Hex_computesExpectedDigest() { - val tmp = File.createTempFile("openclaw-update-hash", ".bin") - try { - tmp.writeText("hello", Charsets.UTF_8) - assertEquals( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret - sha256Hex(tmp), - ) - } finally { - tmp.delete() - } - } -} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt index 58c89f1cd5252..374fe391420a2 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt @@ -23,7 +23,6 @@ class InvokeCommandRegistryTest { OpenClawCapability.Device.rawValue, OpenClawCapability.Notifications.rawValue, OpenClawCapability.System.rawValue, - OpenClawCapability.AppUpdate.rawValue, OpenClawCapability.Photos.rawValue, OpenClawCapability.Contacts.rawValue, OpenClawCapability.Calendar.rawValue, @@ -52,7 +51,6 @@ class InvokeCommandRegistryTest { OpenClawContactsCommand.Add.rawValue, OpenClawCalendarCommand.Events.rawValue, OpenClawCalendarCommand.Add.rawValue, - "app.update", ) private val optionalCommands = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt index 25eda3872e391..103c13341632e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt @@ -31,7 +31,6 @@ class OpenClawProtocolConstantsTest { assertEquals("device", OpenClawCapability.Device.rawValue) assertEquals("notifications", OpenClawCapability.Notifications.rawValue) assertEquals("system", OpenClawCapability.System.rawValue) - assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue) assertEquals("photos", OpenClawCapability.Photos.rawValue) assertEquals("contacts", OpenClawCapability.Contacts.rawValue) assertEquals("calendar", OpenClawCapability.Calendar.rawValue) diff --git a/docs/concepts/features.md b/docs/concepts/features.md index 55f0b2bcd1214..1af146477079d 100644 --- a/docs/concepts/features.md +++ b/docs/concepts/features.md @@ -45,7 +45,7 @@ title: "Features" - Optional voice note transcription hook - WebChat and macOS menu bar app - iOS node with pairing, Canvas, camera, screen recording, location, and voice features -- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera/screen, plus device, notifications, contacts/calendar, motion, photos, SMS, and app update commands +- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera/screen, plus device, notifications, contacts/calendar, motion, photos, and SMS commands Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 37bba45953d85..d1a59d771d17a 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -275,7 +275,6 @@ Available families: - `contacts.search`, `contacts.add` - `calendar.events`, `calendar.add` - `motion.activity`, `motion.pedometer` -- `app.update` Example invokes: @@ -288,7 +287,6 @@ openclaw nodes invoke --node --command photos.latest --params '{" Notes: - Motion commands are capability-gated by available sensors. -- `app.update` is permission + policy gated by the node runtime. ## System commands (node host / mac node) diff --git a/docs/platforms/android.md b/docs/platforms/android.md index fe1683abdbfcc..8619fccdd494d 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -166,4 +166,3 @@ Screen commands: - `contacts.search`, `contacts.add` - `calendar.events`, `calendar.add` - `motion.activity`, `motion.pedometer` - - `app.update` diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index 6094f255748e5..d0ca9f072994d 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -240,12 +240,6 @@ const COMMAND_PROFILES: Record = { expect(readString(obj.diagnostics)).not.toBeNull(); }, }, - "app.update": { - buildParams: () => ({}), - timeoutMs: 20_000, - outcome: "error", - allowedErrorCodes: ["INVALID_REQUEST"], - }, }; function resolveGatewayConnection() { From 1230cefe25b8d4d2ab7cb5a8fae7738332a3462e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 14:48:24 +0530 Subject: [PATCH 015/311] fix(android): remove background location mode --- apps/android/app/src/main/AndroidManifest.xml | 1 - .../main/java/ai/openclaw/app/LocationMode.kt | 2 +- .../main/java/ai/openclaw/app/NodeRuntime.kt | 1 - .../ai/openclaw/app/node/DeviceHandler.kt | 7 --- .../ai/openclaw/app/node/LocationHandler.kt | 20 +------ .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 2 +- .../java/ai/openclaw/app/ui/SettingsSheet.kt | 58 +++---------------- .../ai/openclaw/app/node/DeviceHandlerTest.kt | 1 - docs/nodes/location-command.md | 35 ++++------- 9 files changed, 22 insertions(+), 105 deletions(-) diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 591627eca3fc8..436873c2bfa63 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -11,7 +11,6 @@ android:usesPermissionFlags="neverForLocation" /> - diff --git a/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt index b673ff270566e..f06268b4dcbeb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/LocationMode.kt @@ -3,12 +3,12 @@ package ai.openclaw.app enum class LocationMode(val rawValue: String) { Off("off"), WhileUsing("whileUsing"), - Always("always"), ; companion object { fun fromRawValue(raw: String?): LocationMode { val normalized = raw?.trim()?.lowercase() + if (normalized == "always") return WhileUsing return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index fc821f9fa2e3c..bd9f21c8ea76f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -82,7 +82,6 @@ class NodeRuntime(context: Context) { location = location, json = json, isForeground = { _isForeground.value }, - locationMode = { locationMode.value }, locationPreciseEnabled = { locationPreciseEnabled.value }, ) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index a19890285a88a..71c23102c4033 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -170,13 +170,6 @@ class DeviceHandler( promptableWhenDenied = true, ), ) - put( - "backgroundLocation", - permissionStateJson( - granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION), - promptableWhenDenied = true, - ), - ) put( "sms", permissionStateJson( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt index d925fd7eba7c7..014eead66698e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.pm.PackageManager import android.location.LocationManager import androidx.core.content.ContextCompat -import ai.openclaw.app.LocationMode import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json @@ -17,7 +16,6 @@ class LocationHandler( private val location: LocationCaptureManager, private val json: Json, private val isForeground: () -> Boolean, - private val locationMode: () -> LocationMode, private val locationPreciseEnabled: () -> Boolean, ) { fun hasFineLocationPermission(): Boolean { @@ -34,19 +32,11 @@ class LocationHandler( ) } - fun hasBackgroundLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } - suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult { - val mode = locationMode() - if (!isForeground() && mode != LocationMode.Always) { + if (!isForeground()) { return GatewaySession.InvokeResult.error( code = "LOCATION_BACKGROUND_UNAVAILABLE", - message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always", + message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open", ) } if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { @@ -55,12 +45,6 @@ class LocationHandler( message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", ) } - if (!isForeground() && mode == LocationMode.Always && !hasBackgroundLocationPermission()) { - return GatewaySession.InvokeResult.error( - code = "LOCATION_PERMISSION_REQUIRED", - message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings", - ) - } val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson) val preciseEnabled = locationPreciseEnabled() val accuracy = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 5db2a5e6d78fb..738ab4a4919e1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -1376,7 +1376,7 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Location", - subtitle = "location.get (while app is open unless set to Always later)", + subtitle = "location.get (while app is open)", checked = enableLocation, granted = locationGranted, onCheckedChange = onLocationChange, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index a58d66f8531c5..8b50f2101038e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -114,7 +114,7 @@ fun SettingsSheet(viewModel: MainViewModel) { viewModel.setCameraEnabled(cameraOk) } - var pendingLocationMode by remember { mutableStateOf(null) } + var pendingLocationRequest by remember { mutableStateOf(false) } var pendingPreciseToggle by remember { mutableStateOf(false) } val locationPermissionLauncher = @@ -122,8 +122,6 @@ fun SettingsSheet(viewModel: MainViewModel) { val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true val granted = fineOk || coarseOk - val requestedMode = pendingLocationMode - pendingLocationMode = null if (pendingPreciseToggle) { pendingPreciseToggle = false @@ -131,21 +129,9 @@ fun SettingsSheet(viewModel: MainViewModel) { return@rememberLauncherForActivityResult } - if (!granted) { - viewModel.setLocationMode(LocationMode.Off) - return@rememberLauncherForActivityResult - } - - if (requestedMode != null) { - viewModel.setLocationMode(requestedMode) - if (requestedMode == LocationMode.Always) { - val backgroundOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!backgroundOk) { - openAppSettings(context) - } - } + if (pendingLocationRequest) { + pendingLocationRequest = false + viewModel.setLocationMode(if (granted) LocationMode.WhileUsing else LocationMode.Off) } } @@ -309,7 +295,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } } - fun requestLocationPermissions(targetMode: LocationMode) { + fun requestLocationPermissions() { val fineOk = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED @@ -317,17 +303,9 @@ fun SettingsSheet(viewModel: MainViewModel) { ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED if (fineOk || coarseOk) { - viewModel.setLocationMode(targetMode) - if (targetMode == LocationMode.Always) { - val backgroundOk = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (!backgroundOk) { - openAppSettings(context) - } - } + viewModel.setLocationMode(LocationMode.WhileUsing) } else { - pendingLocationMode = targetMode + pendingLocationRequest = true locationPermissionLauncher.launch( arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), ) @@ -783,20 +761,7 @@ fun SettingsSheet(viewModel: MainViewModel) { 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) }, + onClick = { requestLocationPermissions() }, ) }, ) @@ -816,13 +781,6 @@ fun SettingsSheet(viewModel: MainViewModel) { ) } } - item { - Text( - "Always may require Android Settings to allow background location.", - style = mobileCallout, - color = mobileTextSecondary, - ) - } item { HorizontalDivider(color = mobileBorder) } // Screen diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt index 5574baf6e1414..ab92fb5f30045 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -87,7 +87,6 @@ class DeviceHandlerTest { "camera", "microphone", "location", - "backgroundLocation", "sms", "notificationListener", "notifications", diff --git a/docs/nodes/location-command.md b/docs/nodes/location-command.md index 6ba3f61ec14c9..ddaf05c358416 100644 --- a/docs/nodes/location-command.md +++ b/docs/nodes/location-command.md @@ -1,8 +1,8 @@ --- -summary: "Location command for nodes (location.get), permission modes, and background behavior" +summary: "Location command for nodes (location.get), permission modes, and Android foreground behavior" read_when: - Adding location node support or permissions UI - - Designing background location + push flows + - Designing Android location permissions or foreground behavior title: "Location Command" --- @@ -12,15 +12,15 @@ title: "Location Command" - `location.get` is a node command (via `node.invoke`). - Off by default. -- Settings use a selector: Off / While Using / Always. +- Android app settings use a selector: Off / While Using. - Separate toggle: Precise Location. ## Why a selector (not just a switch) OS permissions are multi-level. We can expose a selector in-app, but the OS still decides the actual grant. -- iOS/macOS: user can choose **While Using** or **Always** in system prompts/Settings. App can request upgrade, but OS may require Settings. -- Android: background location is a separate permission; on Android 10+ it often requires a Settings flow. +- iOS/macOS may expose **While Using** or **Always** in system prompts/Settings. +- Android app currently supports foreground location only. - Precise location is a separate grant (iOS 14+ “Precise”, Android “fine” vs “coarse”). Selector in UI drives our requested mode; actual grant lives in OS settings. @@ -29,13 +29,12 @@ Selector in UI drives our requested mode; actual grant lives in OS settings. Per node device: -- `location.enabledMode`: `off | whileUsing | always` +- `location.enabledMode`: `off | whileUsing` - `location.preciseEnabled`: bool UI behavior: - Selecting `whileUsing` requests foreground permission. -- Selecting `always` first ensures `whileUsing`, then requests background (or sends user to Settings if required). - If OS denies requested level, revert to the highest granted level and show status. ## Permissions mapping (node.permissions) @@ -80,24 +79,11 @@ Errors (stable codes): - `LOCATION_TIMEOUT`: no fix in time. - `LOCATION_UNAVAILABLE`: system failure / no providers. -## Background behavior (future) +## Background behavior -Goal: model can request location even when node is backgrounded, but only when: - -- User selected **Always**. -- OS grants background location. -- App is allowed to run in background for location (iOS background mode / Android foreground service or special allowance). - -Push-triggered flow (future): - -1. Gateway sends a push to the node (silent push or FCM data). -2. Node wakes briefly and requests location from the device. -3. Node forwards payload to Gateway. - -Notes: - -- iOS: Always permission + background location mode required. Silent push may be throttled; expect intermittent failures. -- Android: background location may require a foreground service; otherwise, expect denial. +- Android app denies `location.get` while backgrounded. +- Keep OpenClaw open when requesting location on Android. +- Other node platforms may differ. ## Model/tooling integration @@ -109,5 +95,4 @@ Notes: - Off: “Location sharing is disabled.” - While Using: “Only when OpenClaw is open.” -- Always: “Allow background location. Requires system permission.” - Precise: “Use precise GPS location. Toggle off to share approximate location.” From 46145fde198835a960680ef62a1f687eb01ea854 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 15:07:56 +0530 Subject: [PATCH 016/311] fix(android): remove mic and screen foreground services --- apps/android/app/src/main/AndroidManifest.xml | 4 +- .../main/java/ai/openclaw/app/MainActivity.kt | 4 - .../java/ai/openclaw/app/MainViewModel.kt | 3 - .../ai/openclaw/app/NodeForegroundService.kt | 30 +--- .../main/java/ai/openclaw/app/NodeRuntime.kt | 28 ++- .../ai/openclaw/app/ScreenCaptureRequester.kt | 65 ------- .../ai/openclaw/app/node/DeviceHandler.kt | 8 - .../app/node/InvokeCommandRegistry.kt | 6 - .../ai/openclaw/app/node/InvokeDispatcher.kt | 5 - .../ai/openclaw/app/node/ScreenHandler.kt | 25 --- .../openclaw/app/node/ScreenRecordManager.kt | 165 ------------------ .../app/protocol/OpenClawProtocolConstants.kt | 10 -- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 2 +- .../java/ai/openclaw/app/ui/SettingsSheet.kt | 6 +- .../ai/openclaw/app/node/DeviceHandlerTest.kt | 1 - .../app/node/InvokeCommandRegistryTest.kt | 1 - .../protocol/OpenClawProtocolConstantsTest.kt | 6 - docs/concepts/features.md | 2 +- docs/index.md | 4 +- docs/nodes/index.md | 7 +- docs/platforms/android.md | 8 +- .../android-node.capabilities.live.test.ts | 11 +- 22 files changed, 30 insertions(+), 371 deletions(-) delete mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt delete mode 100644 apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt delete mode 100644 apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 436873c2bfa63..f9bf03b1a3d37 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -3,8 +3,6 @@ - - + android:foregroundServiceType="dataSync" /> = runtime.canvasRehydratePending val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText val camera: CameraCaptureManager = runtime.camera - val screenRecorder: ScreenRecordManager = runtime.screenRecorder val sms: SmsManager = runtime.sms val gateways: StateFlow> = runtime.gateways @@ -38,7 +36,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val cameraHud: StateFlow = runtime.cameraHud val cameraFlashToken: StateFlow = runtime.cameraFlashToken - val screenRecordActive: StateFlow = runtime.screenRecordActive val instanceId: StateFlow = runtime.instanceId val displayName: StateFlow = runtime.displayName diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index 684849b3e8628..5761567ebcc44 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -5,13 +5,10 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.app.PendingIntent -import android.Manifest import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.pm.ServiceInfo import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -23,14 +20,13 @@ import kotlinx.coroutines.launch class NodeForegroundService : Service() { private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var notificationJob: Job? = null - private var lastRequiresMic = false private var didStartForeground = false override fun onCreate() { super.onCreate() ensureChannel() val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") - startForegroundWithTypes(notification = initial, requiresMic = false) + startForegroundWithTypes(notification = initial) val runtime = (application as NodeApp).runtime notificationJob = @@ -53,11 +49,8 @@ class NodeForegroundService : Service() { } val text = (server?.let { "$status · $it" } ?: status) + micSuffix - val requiresMic = - micEnabled && hasRecordAudioPermission() startForegroundWithTypes( notification = buildNotification(title = title, text = text), - requiresMic = requiresMic, ) } } @@ -135,30 +128,15 @@ class NodeForegroundService : Service() { mgr.notify(NOTIFICATION_ID, notification) } - private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) { - if (didStartForeground && requiresMic == lastRequiresMic) { + private fun startForegroundWithTypes(notification: Notification) { + if (didStartForeground) { updateNotification(notification) return } - - lastRequiresMic = requiresMic - val types = - if (requiresMic) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } - startForeground(NOTIFICATION_ID, notification, types) + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) didStartForeground = true } - private fun hasRecordAudioPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - ) - } - companion object { private const val CHANNEL_ID = "connection" private const val NOTIFICATION_ID = 1 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index bd9f21c8ea76f..c4e5f6a5b1d05 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -50,7 +50,6 @@ class NodeRuntime(context: Context) { val canvas = CanvasController() val camera = CameraCaptureManager(appContext) val location = LocationCaptureManager(appContext) - val screenRecorder = ScreenRecordManager(appContext) val sms = SmsManager(appContext) private val json = Json { ignoreUnknownKeys = true } @@ -113,12 +112,6 @@ class NodeRuntime(context: Context) { appContext = appContext, ) - private val screenHandler: ScreenHandler = ScreenHandler( - screenRecorder = screenRecorder, - setScreenRecordActive = { _screenRecordActive.value = it }, - invokeErrorFromThrowable = { invokeErrorFromThrowable(it) }, - ) - private val smsHandlerImpl: SmsHandler = SmsHandler( sms = sms, ) @@ -153,7 +146,6 @@ class NodeRuntime(context: Context) { contactsHandler = contactsHandler, calendarHandler = calendarHandler, motionHandler = motionHandler, - screenHandler = screenHandler, smsHandler = smsHandlerImpl, a2uiHandler = a2uiHandler, debugHandler = debugHandler, @@ -199,9 +191,6 @@ class NodeRuntime(context: Context) { private val _cameraFlashToken = MutableStateFlow(0L) val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow() - 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) @@ -616,6 +605,9 @@ class NodeRuntime(context: Context) { fun setForeground(value: Boolean) { _isForeground.value = value + if (!value) { + stopActiveVoiceSession() + } } fun setDisplayName(value: String) { @@ -660,11 +652,7 @@ class NodeRuntime(context: Context) { fun setVoiceScreenActive(active: Boolean) { if (!active) { - // User left voice screen — stop mic and TTS - talkMode.ttsOnAllResponses = false - talkMode.stopTts() - micCapture.setMicEnabled(false) - prefs.setTalkEnabled(false) + stopActiveVoiceSession() } // Don't re-enable on active=true; mic toggle drives that } @@ -693,6 +681,14 @@ class NodeRuntime(context: Context) { talkMode.setPlaybackEnabled(value) } + private fun stopActiveVoiceSession() { + talkMode.ttsOnAllResponses = false + talkMode.stopTts() + micCapture.setMicEnabled(false) + prefs.setTalkEnabled(false) + externalAudioCaptureActive.value = false + } + fun refreshGatewayConnection() { val endpoint = connectedEndpoint ?: run { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt deleted file mode 100644 index 77711f27ca719..0000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/ScreenCaptureRequester.kt +++ /dev/null @@ -1,65 +0,0 @@ -package ai.openclaw.app - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.media.projection.MediaProjectionManager -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -class ScreenCaptureRequester(private val activity: ComponentActivity) { - data class CaptureResult(val resultCode: Int, val data: Intent) - - private val mutex = Mutex() - private var pending: CompletableDeferred? = null - - private val launcher: ActivityResultLauncher = - activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val p = pending - pending = null - val data = result.data - if (result.resultCode == Activity.RESULT_OK && data != null) { - p?.complete(CaptureResult(result.resultCode, data)) - } else { - p?.complete(null) - } - } - - suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? = - mutex.withLock { - val proceed = showRationaleDialog() - if (!proceed) return null - - val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - val intent = mgr.createScreenCaptureIntent() - - val deferred = CompletableDeferred() - pending = deferred - withContext(Dispatchers.Main) { launcher.launch(intent) } - - withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } } - } - - private suspend fun showRationaleDialog(): Boolean = - withContext(Dispatchers.Main) { - suspendCancellableCoroutine { cont -> - AlertDialog.Builder(activity) - .setTitle("Screen recording required") - .setMessage("OpenClaw needs to record the screen for this command.") - .setPositiveButton("Continue") { _, _ -> cont.resume(true) } - .setNegativeButton("Not now") { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index 71c23102c4033..de3b24df19350 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -219,14 +219,6 @@ class DeviceHandler( promptableWhenDenied = true, ), ) - // Screen capture on Android is interactive per-capture consent, not a sticky app permission. - put( - "screenCapture", - permissionStateJson( - granted = false, - promptableWhenDenied = true, - ), - ) }, ) }.toString() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt index adb9b6030bf9f..5ce86340965e3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt @@ -11,7 +11,6 @@ import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawMotionCommand import ai.openclaw.app.protocol.OpenClawNotificationsCommand import ai.openclaw.app.protocol.OpenClawPhotosCommand -import ai.openclaw.app.protocol.OpenClawScreenCommand import ai.openclaw.app.protocol.OpenClawSmsCommand import ai.openclaw.app.protocol.OpenClawSystemCommand @@ -59,7 +58,6 @@ object InvokeCommandRegistry { val capabilityManifest: List = listOf( NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue), - NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue), NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue), NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue), NodeCapabilitySpec(name = OpenClawCapability.System.rawValue), @@ -122,10 +120,6 @@ object InvokeCommandRegistry { name = OpenClawCanvasA2UICommand.Reset.rawValue, requiresForeground = true, ), - InvokeCommandSpec( - name = OpenClawScreenCommand.Record.rawValue, - requiresForeground = true, - ), InvokeCommandSpec( name = OpenClawSystemCommand.Notify.rawValue, ), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index 0e3fe458a7f6c..f2b7915900968 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -10,7 +10,6 @@ import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawMotionCommand import ai.openclaw.app.protocol.OpenClawNotificationsCommand -import ai.openclaw.app.protocol.OpenClawScreenCommand import ai.openclaw.app.protocol.OpenClawSmsCommand import ai.openclaw.app.protocol.OpenClawSystemCommand @@ -25,7 +24,6 @@ class InvokeDispatcher( private val contactsHandler: ContactsHandler, private val calendarHandler: CalendarHandler, private val motionHandler: MotionHandler, - private val screenHandler: ScreenHandler, private val smsHandler: SmsHandler, private val a2uiHandler: A2UIHandler, private val debugHandler: DebugHandler, @@ -160,9 +158,6 @@ class InvokeDispatcher( OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson) OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson) - // Screen command - OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson) - // SMS command OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt deleted file mode 100644 index ebbe6f415d6d6..0000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenHandler.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ai.openclaw.app.node - -import ai.openclaw.app.gateway.GatewaySession - -class ScreenHandler( - private val screenRecorder: ScreenRecordManager, - private val setScreenRecordActive: (Boolean) -> Unit, - private val invokeErrorFromThrowable: (Throwable) -> Pair, -) { - suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult { - setScreenRecordActive(true) - try { - val res = - try { - screenRecorder.record(paramsJson) - } catch (err: Throwable) { - val (code, message) = invokeErrorFromThrowable(err) - return GatewaySession.InvokeResult.error(code = code, message = message) - } - return GatewaySession.InvokeResult.ok(res.payloadJson) - } finally { - setScreenRecordActive(false) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt deleted file mode 100644 index bae5587c4cc4e..0000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ScreenRecordManager.kt +++ /dev/null @@ -1,165 +0,0 @@ -package ai.openclaw.app.node - -import android.content.Context -import android.hardware.display.DisplayManager -import android.media.MediaRecorder -import android.media.projection.MediaProjectionManager -import android.os.Build -import android.util.Base64 -import ai.openclaw.app.ScreenCaptureRequester -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.JsonObject -import java.io.File -import kotlin.math.roundToInt - -class ScreenRecordManager(private val context: Context) { - data class Payload(val payloadJson: String) - - @Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null - @Volatile private var permissionRequester: ai.openclaw.app.PermissionRequester? = null - - fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) { - screenCaptureRequester = requester - } - - fun attachPermissionRequester(requester: ai.openclaw.app.PermissionRequester) { - permissionRequester = requester - } - - suspend fun record(paramsJson: String?): Payload = - withContext(Dispatchers.Default) { - val requester = - screenCaptureRequester - ?: throw IllegalStateException( - "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", - ) - - val params = parseJsonParamsObject(paramsJson) - val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000) - val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0) - val fpsInt = fps.roundToInt().coerceIn(1, 60) - val screenIndex = parseScreenIndex(params) - val includeAudio = parseIncludeAudio(params) ?: true - val format = parseString(params, key = "format") - if (format != null && format.lowercase() != "mp4") { - throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4") - } - if (screenIndex != null && screenIndex != 0) { - throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android") - } - - val capture = requester.requestCapture() - ?: throw IllegalStateException( - "SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission", - ) - - val mgr = - context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager - val projection = mgr.getMediaProjection(capture.resultCode, capture.data) - ?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable") - - val metrics = context.resources.displayMetrics - val width = metrics.widthPixels - val height = metrics.heightPixels - val densityDpi = metrics.densityDpi - - val file = File.createTempFile("openclaw-screen-", ".mp4") - if (includeAudio) ensureMicPermission() - - val recorder = createMediaRecorder() - var virtualDisplay: android.hardware.display.VirtualDisplay? = null - try { - if (includeAudio) { - recorder.setAudioSource(MediaRecorder.AudioSource.MIC) - } - recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) - if (includeAudio) { - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - recorder.setAudioChannels(1) - recorder.setAudioSamplingRate(44_100) - recorder.setAudioEncodingBitRate(96_000) - } - recorder.setVideoSize(width, height) - recorder.setVideoFrameRate(fpsInt) - recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt)) - recorder.setOutputFile(file.absolutePath) - recorder.prepare() - - val surface = recorder.surface - virtualDisplay = - projection.createVirtualDisplay( - "openclaw-screen", - width, - height, - densityDpi, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - surface, - null, - null, - ) - - recorder.start() - delay(durationMs.toLong()) - } finally { - try { - recorder.stop() - } catch (_: Throwable) { - // ignore - } - recorder.reset() - recorder.release() - virtualDisplay?.release() - projection.stop() - } - - val bytes = withContext(Dispatchers.IO) { file.readBytes() } - file.delete() - val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) - Payload( - """{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""", - ) - } - - private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context) - - private suspend fun ensureMicPermission() { - val granted = - androidx.core.content.ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.RECORD_AUDIO, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (granted) return - - val requester = - permissionRequester - ?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO)) - if (results[android.Manifest.permission.RECORD_AUDIO] != true) { - throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission") - } - } - - private fun parseDurationMs(params: JsonObject?): Int? = - parseJsonInt(params, "durationMs") - - private fun parseFps(params: JsonObject?): Double? = - parseJsonDouble(params, "fps") - - private fun parseScreenIndex(params: JsonObject?): Int? = - parseJsonInt(params, "screenIndex") - - private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio") - - private fun parseString(params: JsonObject?, key: String): String? = - parseJsonString(params, key) - - private fun estimateBitrate(width: Int, height: Int, fps: Int): Int { - val pixels = width.toLong() * height.toLong() - val raw = (pixels * fps.toLong() * 2L).toInt() - return raw.coerceIn(1_000_000, 12_000_000) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt index 8c9a1f4c22012..95ba2912b09c0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt @@ -3,7 +3,6 @@ package ai.openclaw.app.protocol enum class OpenClawCapability(val rawValue: String) { Canvas("canvas"), Camera("camera"), - Screen("screen"), Sms("sms"), VoiceWake("voiceWake"), Location("location"), @@ -51,15 +50,6 @@ enum class OpenClawCameraCommand(val rawValue: String) { } } -enum class OpenClawScreenCommand(val rawValue: String) { - Record("screen.record"), - ; - - companion object { - const val NamespacePrefix: String = "screen." - } -} - enum class OpenClawSmsCommand(val rawValue: String) { Send("sms.send"), ; diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 738ab4a4919e1..8810ea93fcbab 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -1402,7 +1402,7 @@ private fun PermissionsStep( InlineDivider() PermissionToggleRow( title = "Microphone", - subtitle = "Voice tab transcription", + subtitle = "Foreground Voice tab transcription", checked = enableMicrophone, granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO), onCheckedChange = onMicrophoneChange, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index 8b50f2101038e..a3f7868fa9073 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -402,9 +402,9 @@ fun SettingsSheet(viewModel: MainViewModel) { supportingContent = { Text( if (micPermissionGranted) { - "Granted. Use the Voice tab mic button to capture transcript." + "Granted. Use the Voice tab mic button to capture transcript while the app is open." } else { - "Required for Voice tab transcription." + "Required for foreground Voice tab transcription." }, style = mobileCallout, ) @@ -431,7 +431,7 @@ fun SettingsSheet(viewModel: MainViewModel) { } item { Text( - "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.", + "Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.", style = mobileCallout, color = mobileTextSecondary, ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt index ab92fb5f30045..e40e2b164aede 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/DeviceHandlerTest.kt @@ -94,7 +94,6 @@ class DeviceHandlerTest { "contacts", "calendar", "motion", - "screenCapture", ) for (key in expected) { val state = permissions.getValue(key).jsonObject diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt index 374fe391420a2..d3825a5720e12 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt @@ -19,7 +19,6 @@ class InvokeCommandRegistryTest { private val coreCapabilities = setOf( OpenClawCapability.Canvas.rawValue, - OpenClawCapability.Screen.rawValue, OpenClawCapability.Device.rawValue, OpenClawCapability.Notifications.rawValue, OpenClawCapability.System.rawValue, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt index 103c13341632e..8dd844dee83ad 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt @@ -24,7 +24,6 @@ class OpenClawProtocolConstantsTest { fun capabilitiesUseStableStrings() { assertEquals("canvas", OpenClawCapability.Canvas.rawValue) assertEquals("camera", OpenClawCapability.Camera.rawValue) - assertEquals("screen", OpenClawCapability.Screen.rawValue) assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue) assertEquals("location", OpenClawCapability.Location.rawValue) assertEquals("sms", OpenClawCapability.Sms.rawValue) @@ -44,11 +43,6 @@ class OpenClawProtocolConstantsTest { assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue) } - @Test - fun screenCommandsUseStableStrings() { - assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue) - } - @Test fun notificationsCommandsUseStableStrings() { assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue) diff --git a/docs/concepts/features.md b/docs/concepts/features.md index 1af146477079d..1d04af9187d05 100644 --- a/docs/concepts/features.md +++ b/docs/concepts/features.md @@ -45,7 +45,7 @@ title: "Features" - Optional voice note transcription hook - WebChat and macOS menu bar app - iOS node with pairing, Canvas, camera, screen recording, location, and voice features -- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera/screen, plus device, notifications, contacts/calendar, motion, photos, and SMS commands +- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera, plus device, notifications, contacts/calendar, motion, photos, and SMS commands Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only diff --git a/docs/index.md b/docs/index.md index 2821cb1c84f30..f838ebf4cab4f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -89,7 +89,7 @@ The Gateway is the single source of truth for sessions, routing, and channel con Browser dashboard for chat, config, sessions, and nodes. - Pair iOS and Android nodes for Canvas, camera/screen, and voice-enabled workflows. + Pair iOS and Android nodes for Canvas, camera, and voice-enabled workflows. @@ -164,7 +164,7 @@ Example: Channel-specific setup for WhatsApp, Telegram, Discord, and more. - iOS and Android nodes with pairing, Canvas, camera/screen, and device actions. + iOS and Android nodes with pairing, Canvas, camera, and device actions. Common fixes and troubleshooting entry point. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index d1a59d771d17a..1b9b2bfaea204 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -216,7 +216,7 @@ Notes: ## Screen recordings (nodes) -Nodes expose `screen.record` (mp4). Example: +Supported nodes expose `screen.record` (mp4). Example: ```bash openclaw nodes screen record --node --duration 10s --fps 10 @@ -225,10 +225,9 @@ openclaw nodes screen record --node --duration 10s --fps 10 --no- Notes: -- `screen.record` requires the node app to be foregrounded. -- Android will show the system screen-capture prompt before recording. +- `screen.record` availability depends on node platform. - Screen recordings are clamped to `<= 60s`. -- `--no-audio` disables microphone capture (supported on iOS/Android; macOS uses system capture audio). +- `--no-audio` disables microphone capture on supported platforms. - Use `--screen ` to select a display when multiple screens are available. ## Location (nodes) diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 8619fccdd494d..4df71b83e733c 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -118,7 +118,7 @@ The Android Chat tab supports session selection (default `main`, plus other exis - Send: `chat.send` - Push updates (best-effort): `chat.subscribe` → `event:"chat"` -### 7) Canvas + screen + camera +### 7) Canvas + camera #### Gateway Canvas Host (recommended for web content) @@ -151,13 +151,9 @@ Camera commands (foreground only; permission-gated): See [Camera node](/nodes/camera) for parameters and CLI helpers. -Screen commands: - -- `screen.record` (mp4; foreground only) - ### 8) Voice + expanded Android command surface -- Voice: Android uses a single mic on/off flow in the Voice tab with transcript capture and TTS playback (ElevenLabs when configured, system TTS fallback). +- Voice: Android uses a single mic on/off flow in the Voice tab with transcript capture and TTS playback (ElevenLabs when configured, system TTS fallback). Voice stops when the app leaves the foreground. - Voice wake/talk-mode toggles are currently removed from Android UX/runtime. - Additional Android command families (availability depends on device + permissions): - `device.status`, `device.info`, `device.permissions`, `device.health` diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index d0ca9f072994d..80b4c8ae687a5 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -12,7 +12,7 @@ import { resolveGatewayCredentialsFromConfig } from "./credentials.js"; const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); const LIVE_ANDROID_NODE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ANDROID_NODE); const describeLive = LIVE && LIVE_ANDROID_NODE ? describe : describe.skip; -const SKIPPED_INTERACTIVE_COMMANDS = new Set(["screen.record"]); +const SKIPPED_INTERACTIVE_COMMANDS = new Set(); type CommandOutcome = "success" | "error"; @@ -120,15 +120,6 @@ const COMMAND_PROFILES: Record = { timeoutMs: 30_000, outcome: "success", }, - "screen.record": { - buildParams: () => ({ durationMs: 1500, fps: 8, includeAudio: false }), - timeoutMs: 60_000, - outcome: "success", - onSuccess: (payload) => { - const obj = assertObjectPayload("screen.record", payload); - expect(readString(obj.base64)).not.toBeNull(); - }, - }, "camera.list": { buildParams: () => ({}), timeoutMs: 20_000, From 709e11ea7076c820ffb99816bc4626b4cf7a4194 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 16:17:38 +0530 Subject: [PATCH 017/311] build(android): bump release version code --- apps/android/app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 5f5850532979f..e300d4fb2e4fc 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -63,7 +63,7 @@ android { applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 202603080 + versionCode = 202603081 versionName = "2026.3.8" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) From 04b4b48077571ee6395b4948a6ed38a53c0bcaf2 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 16:20:52 +0530 Subject: [PATCH 018/311] fix(android): persist legacy location mode migration --- .../main/java/ai/openclaw/app/SecurePrefs.kt | 15 +++++++++--- .../java/ai/openclaw/app/SecurePrefsTest.kt | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index cc996cf65d89f..b7e72ee41265b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -19,6 +19,7 @@ class SecurePrefs(context: Context) { companion object { val defaultWakeWords: List = listOf("openclaw", "claude") private const val displayNameKey = "node.displayName" + private const val locationModeKey = "location.enabledMode" private const val voiceWakeModeKey = "voiceWake.mode" private const val plainPrefsName = "openclaw.node" private const val securePrefsName = "openclaw.node.secure" @@ -46,8 +47,7 @@ class SecurePrefs(context: Context) { private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true)) val cameraEnabled: StateFlow = _cameraEnabled - private val _locationMode = - MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off"))) + private val _locationMode = MutableStateFlow(loadLocationMode()) val locationMode: StateFlow = _locationMode private val _locationPreciseEnabled = @@ -120,7 +120,7 @@ class SecurePrefs(context: Context) { } fun setLocationMode(mode: LocationMode) { - plainPrefs.edit { putString("location.enabledMode", mode.rawValue) } + plainPrefs.edit { putString(locationModeKey, mode.rawValue) } _locationMode.value = mode } @@ -290,6 +290,15 @@ class SecurePrefs(context: Context) { return resolved } + private fun loadLocationMode(): LocationMode { + val raw = plainPrefs.getString(locationModeKey, "off") + val resolved = LocationMode.fromRawValue(raw) + if (raw?.trim()?.lowercase() == "always") { + plainPrefs.edit { putString(locationModeKey, resolved.rawValue) } + } + return resolved + } + private fun loadWakeWords(): List { val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim() if (raw.isNullOrEmpty()) return defaultWakeWords diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt new file mode 100644 index 0000000000000..cd72bf75dff0b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -0,0 +1,23 @@ +package ai.openclaw.app + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class SecurePrefsTest { + @Test + fun loadLocationMode_migratesLegacyAlwaysValue() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().putString("location.enabledMode", "always").commit() + + val prefs = SecurePrefs(context) + + assertEquals(LocationMode.WhileUsing, prefs.locationMode.value) + assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null)) + } +} From eb0758e1722c05e4c6cb359ae8983cf5cf372c45 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 16:24:14 +0530 Subject: [PATCH 019/311] docs(changelog): note Android Play policy cutovers --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f87d5006da670..fa17671569f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. +- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. ## 2026.3.7 From 53fb317e7fa523c424992d28706c7c24549ad306 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:22:46 +0000 Subject: [PATCH 020/311] fix(macos): clean swiftformat pass and sendable warning --- .../OpenClaw/CameraCaptureService.swift | 4 +- apps/macos/Sources/OpenClaw/ConfigStore.swift | 2 +- .../OpenClaw/ConnectionModeResolver.swift | 4 +- .../Sources/OpenClaw/ControlChannel.swift | 2 +- apps/macos/Sources/OpenClaw/CronModels.swift | 4 +- .../Sources/OpenClaw/DeviceModelCatalog.swift | 2 +- .../Sources/OpenClaw/DiagnosticsFileLog.swift | 2 +- .../Sources/OpenClaw/ExecApprovals.swift | 10 ++--- .../ExecApprovalsGatewayPrompter.swift | 2 +- .../OpenClaw/ExecApprovalsSocket.swift | 2 +- .../OpenClaw/ExecCommandResolution.swift | 2 +- .../Sources/OpenClaw/GatewayConnection.swift | 14 +++--- .../OpenClaw/GatewayEndpointStore.swift | 6 +-- .../Sources/OpenClaw/GatewayEnvironment.swift | 2 +- apps/macos/Sources/OpenClaw/HealthStore.swift | 14 +++--- .../HostEnvSecurityPolicy.generated.swift | 8 ++-- apps/macos/Sources/OpenClaw/Launchctl.swift | 4 +- .../NodeMode/MacNodeBrowserProxy.swift | 4 +- .../NodeMode/MacNodeScreenCommands.swift | 4 +- .../OpenClaw/OverlayPanelFactory.swift | 10 ++--- .../PeekabooBridgeHostCoordinator.swift | 2 +- .../macos/Sources/OpenClaw/PortGuardian.swift | 2 +- .../OpenClaw/SessionMenuPreviewView.swift | 6 +-- .../Sources/OpenClaw/TalkAudioPlayer.swift | 2 +- .../Sources/OpenClaw/VoiceWakeChime.swift | 2 +- .../Sources/OpenClaw/VoiceWakeForwarder.swift | 2 +- .../Sources/OpenClaw/WebChatSwiftUI.swift | 2 +- .../GatewayDiscoveryModel.swift | 8 ++-- .../TailscaleServeGatewayDiscovery.swift | 12 ++--- .../WideAreaGatewayDiscovery.swift | 4 +- .../AgentEventStoreTests.swift | 5 +-- .../AgentWorkspaceTests.swift | 17 ++++--- .../AnyCodableEncodingTests.swift | 6 +-- .../AudioInputDeviceObserverTests.swift | 6 +-- .../OpenClawIPCTests/CLIInstallerTests.swift | 2 +- .../CameraCaptureServiceTests.swift | 6 +-- .../OpenClawIPCTests/CameraIPCTests.swift | 8 ++-- .../CanvasFileWatcherTests.swift | 2 +- .../OpenClawIPCTests/CanvasIPCTests.swift | 6 +-- .../CanvasWindowSmokeTests.swift | 4 +- .../ChannelsSettingsSmokeTests.swift | 4 +- .../CommandResolverTests.swift | 20 ++++----- .../OpenClawIPCTests/ConfigStoreTests.swift | 8 ++-- .../OpenClawIPCTests/CoverageDumpTests.swift | 2 +- .../CritterIconRendererTests.swift | 7 ++- .../CronJobEditorSmokeTests.swift | 10 ++--- .../OpenClawIPCTests/CronModelsTests.swift | 29 ++++++------ .../DeepLinkAgentPolicyTests.swift | 12 ++--- .../DeviceModelCatalogTests.swift | 9 ++-- .../OpenClawIPCTests/ExecAllowlistTests.swift | 32 +++++++------- .../ExecApprovalHelpersTests.swift | 10 ++--- .../ExecApprovalsGatewayPrompterTests.swift | 7 ++- .../ExecApprovalsSocketPathGuardTests.swift | 6 +-- .../ExecApprovalsStoreRefactorTests.swift | 10 ++--- .../ExecHostRequestEvaluatorTests.swift | 8 ++-- .../ExecSystemRunCommandValidatorTests.swift | 2 +- .../FileHandleLegacyAPIGuardTests.swift | 4 +- .../FileHandleSafeReadTests.swift | 10 ++--- .../GatewayAgentChannelTests.swift | 8 ++-- .../GatewayAutostartPolicyTests.swift | 4 +- .../GatewayChannelConfigureTests.swift | 12 ++--- .../GatewayChannelConnectTests.swift | 6 +-- .../GatewayChannelRequestTests.swift | 4 +- .../GatewayChannelShutdownTests.swift | 4 +- .../GatewayConnectionControlTests.swift | 4 +- .../GatewayDiscoveryHelpersTests.swift | 15 +++---- .../GatewayDiscoveryModelTests.swift | 28 ++++++------ .../GatewayEndpointStoreTests.swift | 44 +++++++++---------- .../GatewayEnvironmentTests.swift | 10 ++--- .../GatewayFrameDecodeTests.swift | 8 ++-- .../GatewayLaunchAgentManagerTests.swift | 6 +-- .../GatewayProcessManagerTests.swift | 2 +- .../GatewayWebSocketTestSupport.swift | 9 ++-- .../OpenClawIPCTests/HealthDecodeTests.swift | 8 ++-- .../HealthStoreStateTests.swift | 4 +- .../HostEnvSanitizerTests.swift | 6 +-- .../HoverHUDControllerTests.swift | 2 +- .../InstancesSettingsSmokeTests.swift | 4 +- .../InstancesStoreTests.swift | 4 +- .../OpenClawIPCTests/LogLocatorTests.swift | 4 +- .../LowCoverageHelperTests.swift | 26 +++++------ .../LowCoverageViewSmokeTests.swift | 20 ++++----- .../MacGatewayChatTransportMappingTests.swift | 14 +++--- .../MacNodeBrowserProxyTests.swift | 2 +- .../MacNodeRuntimeTests.swift | 21 +++++---- .../MasterDiscoveryMenuSmokeTests.swift | 6 +-- .../MenuContentSmokeTests.swift | 8 ++-- .../MenuSessionsInjectorTests.swift | 6 +-- .../ModelCatalogLoaderTests.swift | 5 +-- .../NixModeStableSuiteTests.swift | 4 +- .../NodeManagerPathsTests.swift | 6 +-- .../NodePairingApprovalPrompterTests.swift | 2 +- .../NodePairingReconcilePolicyTests.swift | 6 +-- .../OnboardingCoverageTests.swift | 2 +- .../OnboardingViewSmokeTests.swift | 8 ++-- .../OnboardingWizardStepViewTests.swift | 4 +- .../OpenClawConfigFileTests.swift | 20 ++++----- .../PermissionManagerLocationTests.swift | 9 ++-- .../PermissionManagerTests.swift | 10 ++--- .../Tests/OpenClawIPCTests/Placeholder.swift | 2 +- .../RemotePortTunnelTests.swift | 6 +-- .../RuntimeLocatorTests.swift | 12 ++--- .../ScreenshotSizeTests.swift | 5 +-- .../Tests/OpenClawIPCTests/SemverTests.swift | 6 +-- .../OpenClawIPCTests/SessionDataTests.swift | 9 ++-- .../SessionMenuPreviewTests.swift | 4 +- .../SettingsViewSmokeTests.swift | 26 +++++------ .../SkillsSettingsSmokeTests.swift | 6 +-- .../TailscaleIntegrationSectionTests.swift | 6 +-- .../TailscaleServeGatewayDiscoveryTests.swift | 12 ++--- .../TalkAudioPlayerTests.swift | 4 +- .../TalkModeConfigParsingTests.swift | 6 +-- .../OpenClawIPCTests/UtilitiesTests.swift | 12 ++--- .../VoicePushToTalkHotkeyTests.swift | 2 +- .../VoicePushToTalkTests.swift | 10 ++--- .../VoiceWakeForwarderTests.swift | 4 +- .../VoiceWakeGlobalSettingsSyncTests.swift | 11 +++-- .../VoiceWakeHelpersTests.swift | 12 ++--- .../VoiceWakeOverlayControllerTests.swift | 8 ++-- .../VoiceWakeOverlayTests.swift | 8 ++-- .../VoiceWakeOverlayViewSmokeTests.swift | 6 +-- .../VoiceWakeRuntimeTests.swift | 20 ++++----- .../VoiceWakeTesterTests.swift | 4 +- .../WebChatMainSessionKeyTests.swift | 12 ++--- .../WebChatManagerTests.swift | 2 +- .../WebChatSwiftUISmokeTests.swift | 6 +-- .../WideAreaGatewayDiscoveryTests.swift | 3 +- .../WindowPlacementTests.swift | 13 +++--- .../WorkActivityStoreTests.swift | 9 ++-- 129 files changed, 505 insertions(+), 512 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift index 29f532dce2ef0..110a574e50973 100644 --- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift +++ b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift @@ -6,14 +6,14 @@ import OpenClawKit import OSLog actor CameraCaptureService { - struct CameraDeviceInfo: Encodable, Sendable { + struct CameraDeviceInfo: Encodable { let id: String let name: String let position: String let deviceType: String } - enum CameraError: LocalizedError, Sendable { + enum CameraError: LocalizedError { case cameraUnavailable case microphoneUnavailable case permissionDenied(kind: String) diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift index 8fd779c645674..29146aca7e186 100644 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -2,7 +2,7 @@ import Foundation import OpenClawProtocol enum ConfigStore { - struct Overrides: Sendable { + struct Overrides { var isRemoteMode: (@Sendable () async -> Bool)? var loadLocal: (@MainActor @Sendable () -> [String: Any])? var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)? diff --git a/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift b/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift index 60c6fab9d5608..5066739474945 100644 --- a/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift +++ b/apps/macos/Sources/OpenClaw/ConnectionModeResolver.swift @@ -1,13 +1,13 @@ import Foundation -enum EffectiveConnectionModeSource: Sendable, Equatable { +enum EffectiveConnectionModeSource: Equatable { case configMode case configRemoteURL case userDefaults case onboarding } -struct EffectiveConnectionMode: Sendable, Equatable { +struct EffectiveConnectionMode: Equatable { let mode: AppState.ConnectionMode let source: EffectiveConnectionModeSource } diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 6fb81ce794173..aecf9539ef5e3 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -14,7 +14,7 @@ struct ControlHeartbeatEvent: Codable { let reason: String? } -struct ControlAgentEvent: Codable, Sendable, Identifiable { +struct ControlAgentEvent: Codable, Identifiable { var id: String { "\(self.runId)-\(self.seq)" } diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index cbfbc061d6ae4..e0ce46c13da35 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -226,7 +226,7 @@ struct CronJob: Identifiable, Codable, Equatable { } } -struct CronEvent: Codable, Sendable { +struct CronEvent: Codable { let jobId: String let action: String let runAtMs: Int? @@ -237,7 +237,7 @@ struct CronEvent: Codable, Sendable { let nextRunAtMs: Int? } -struct CronRunLogEntry: Codable, Identifiable, Sendable { +struct CronRunLogEntry: Codable, Identifiable { var id: String { "\(self.jobId)-\(self.ts)" } diff --git a/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift b/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift index ce6dd10c9310f..7e0817c4af6b7 100644 --- a/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift +++ b/apps/macos/Sources/OpenClaw/DeviceModelCatalog.swift @@ -1,6 +1,6 @@ import Foundation -struct DevicePresentation: Sendable { +struct DevicePresentation { let title: String let symbol: String? } diff --git a/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift b/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift index 44baa738bdc23..e3300bf5bde24 100644 --- a/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift +++ b/apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift @@ -7,7 +7,7 @@ actor DiagnosticsFileLog { private let maxBytes: Int64 = 5 * 1024 * 1024 private let maxBackups = 5 - struct Record: Codable, Sendable { + struct Record: Codable { let ts: String let pid: Int32 let category: String diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 0c2c8b932188a..ba49b37cd9fd1 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -84,13 +84,13 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable { } } -enum ExecApprovalDecision: String, Codable, Sendable { +enum ExecApprovalDecision: String, Codable { case allowOnce = "allow-once" case allowAlways = "allow-always" case deny } -enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable { +enum ExecAllowlistPatternValidationReason: String, Codable, Equatable { case empty case missingPathComponent @@ -104,12 +104,12 @@ enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable } } -enum ExecAllowlistPatternValidation: Sendable, Equatable { +enum ExecAllowlistPatternValidation: Equatable { case valid(String) case invalid(ExecAllowlistPatternValidationReason) } -struct ExecAllowlistRejectedEntry: Sendable, Equatable { +struct ExecAllowlistRejectedEntry: Equatable { let id: UUID let pattern: String let reason: ExecAllowlistPatternValidationReason @@ -753,7 +753,7 @@ enum ExecApprovalHelpers { } } -struct ExecEventPayload: Codable, Sendable { +struct ExecEventPayload: Codable { var sessionKey: String var runId: String var host: String diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift index 0da8faadbc4cc..379e8c0f5597e 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift @@ -11,7 +11,7 @@ final class ExecApprovalsGatewayPrompter { private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.gateway") private var task: Task? - struct GatewayApprovalRequest: Codable, Sendable { + struct GatewayApprovalRequest: Codable { var id: String var request: ExecApprovalPromptRequest var createdAtMs: Int diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index bee77ce3e7dd4..a2cc9d5339013 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -5,7 +5,7 @@ import Foundation import OpenClawKit import OSLog -struct ExecApprovalPromptRequest: Codable, Sendable { +struct ExecApprovalPromptRequest: Codable { var command: String var cwd: String? var host: String? diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index 843062b2470d8..91a22153f3c18 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -1,6 +1,6 @@ import Foundation -struct ExecCommandResolution: Sendable { +struct ExecCommandResolution { let rawExecutable: String let resolvedPath: String? let executableName: String diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 13d4365e44881..3075ef12b929b 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -6,7 +6,7 @@ import OSLog private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection") -enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { +enum GatewayAgentChannel: String, Codable, CaseIterable { case last case whatsapp case telegram @@ -33,7 +33,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { } } -struct GatewayAgentInvocation: Sendable { +struct GatewayAgentInvocation { var message: String var sessionKey: String = "main" var thinking: String? @@ -53,7 +53,7 @@ actor GatewayConnection { typealias Config = (url: URL, token: String?, password: String?) - enum Method: String, Sendable { + enum Method: String { case agent case status case setHeartbeats = "set-heartbeats" @@ -428,9 +428,9 @@ actor GatewayConnection { // MARK: - Typed gateway API extension GatewayConnection { - struct ConfigGetSnapshot: Decodable, Sendable { - struct SnapshotConfig: Decodable, Sendable { - struct Session: Decodable, Sendable { + struct ConfigGetSnapshot: Decodable { + struct SnapshotConfig: Decodable { + struct Session: Decodable { let mainKey: String? let scope: String? } @@ -729,7 +729,7 @@ extension GatewayConnection { // MARK: - Cron - struct CronSchedulerStatus: Decodable, Sendable { + struct CronSchedulerStatus: Decodable { let enabled: Bool let storePath: String let jobs: Int diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index bcff00c6a1836..86fa9828baf98 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -2,7 +2,7 @@ import ConcurrencyExtras import Foundation import OSLog -enum GatewayEndpointState: Sendable, Equatable { +enum GatewayEndpointState: Equatable { case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) case connecting(mode: AppState.ConnectionMode, detail: String) case unavailable(mode: AppState.ConnectionMode, reason: String) @@ -24,14 +24,14 @@ actor GatewayEndpointStore { ] private static let remoteConnectingDetail = "Connecting to remote gateway…" private static let staticLogger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint") - private enum EnvOverrideWarningKind: Sendable { + private enum EnvOverrideWarningKind { case token case password } private static let envOverrideWarnings = LockIsolated((token: false, password: false)) - struct Deps: Sendable { + struct Deps { let mode: @Sendable () async -> AppState.ConnectionMode let token: @Sendable () -> String? let password: @Sendable () -> String? diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index 059eb4da6e0cc..0586e19ff70e5 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -3,7 +3,7 @@ import OpenClawIPC import OSLog /// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks. -struct Semver: Comparable, CustomStringConvertible, Sendable { +struct Semver: Comparable, CustomStringConvertible { let major: Int let minor: Int let patch: Int diff --git a/apps/macos/Sources/OpenClaw/HealthStore.swift b/apps/macos/Sources/OpenClaw/HealthStore.swift index 22c1409fca77d..9b534cdb1a438 100644 --- a/apps/macos/Sources/OpenClaw/HealthStore.swift +++ b/apps/macos/Sources/OpenClaw/HealthStore.swift @@ -3,14 +3,14 @@ import Network import Observation import SwiftUI -struct HealthSnapshot: Codable, Sendable { - struct ChannelSummary: Codable, Sendable { - struct Probe: Codable, Sendable { - struct Bot: Codable, Sendable { +struct HealthSnapshot: Codable { + struct ChannelSummary: Codable { + struct Probe: Codable { + struct Bot: Codable { let username: String? } - struct Webhook: Codable, Sendable { + struct Webhook: Codable { let url: String? } @@ -29,13 +29,13 @@ struct HealthSnapshot: Codable, Sendable { let lastProbeAt: Double? } - struct SessionInfo: Codable, Sendable { + struct SessionInfo: Codable { let key: String let updatedAt: Double? let age: Double? } - struct Sessions: Codable, Sendable { + struct Sessions: Codable { let path: String let count: Int let recent: [SessionInfo] diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 2981a60bbf77c..3364cac36c6cd 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -22,7 +22,7 @@ enum HostEnvSecurityPolicy { "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE" + "SSLKEYLOGFILE", ] static let blockedOverrideKeys: Set = [ @@ -50,17 +50,17 @@ enum HostEnvSecurityPolicy { "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME" + "CURL_HOME", ] static let blockedOverridePrefixes: [String] = [ "GIT_CONFIG_", - "NPM_CONFIG_" + "NPM_CONFIG_", ] static let blockedPrefixes: [String] = [ "DYLD_", "LD_", - "BASH_FUNC_" + "BASH_FUNC_", ] } diff --git a/apps/macos/Sources/OpenClaw/Launchctl.swift b/apps/macos/Sources/OpenClaw/Launchctl.swift index cc50fd48ac750..841399bc2091b 100644 --- a/apps/macos/Sources/OpenClaw/Launchctl.swift +++ b/apps/macos/Sources/OpenClaw/Launchctl.swift @@ -1,7 +1,7 @@ import Foundation enum Launchctl { - struct Result: Sendable { + struct Result { let status: Int32 let output: String } @@ -26,7 +26,7 @@ enum Launchctl { } } -struct LaunchAgentPlistSnapshot: Equatable, Sendable { +struct LaunchAgentPlistSnapshot: Equatable { let programArguments: [String] let environment: [String: String] let stdoutPath: String? diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift index 86a2b605cad02..0da6510f60834 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift @@ -122,7 +122,7 @@ actor MacNodeBrowserProxy { } } let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !profile.isEmpty && !queryItems.contains(where: { $0.name == "profile" }) { + if !profile.isEmpty, !queryItems.contains(where: { $0.name == "profile" }) { queryItems.append(URLQueryItem(name: "profile", value: profile)) } if !queryItems.isEmpty { @@ -172,7 +172,7 @@ actor MacNodeBrowserProxy { } if let text = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), - !text.isEmpty + !text.isEmpty { return text } diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift index 6f849fdf03adc..a61867c3c65e4 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeScreenCommands.swift @@ -1,10 +1,10 @@ import Foundation -enum MacNodeScreenCommand: String, Codable, Sendable { +enum MacNodeScreenCommand: String, Codable { case record = "screen.record" } -struct MacNodeScreenRecordParams: Codable, Sendable, Equatable { +struct MacNodeScreenRecordParams: Codable, Equatable { var screenIndex: Int? var durationMs: Int? var fps: Double? diff --git a/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift index b1d6570d81fd9..95f9ae8638d61 100644 --- a/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift +++ b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift @@ -87,7 +87,7 @@ enum OverlayPanelFactory { offsetX: CGFloat = 6, offsetY: CGFloat = 6, duration: TimeInterval = 0.16, - completion: @escaping () -> Void) + completion: @escaping @MainActor @Sendable () -> Void) { let target = window.frame.offsetBy(dx: offsetX, dy: offsetY) NSAnimationContext.runAnimationGroup { context in @@ -96,7 +96,7 @@ enum OverlayPanelFactory { window.animator().setFrame(target, display: true) window.animator().alphaValue = 0 } completionHandler: { - completion() + Task { @MainActor in completion() } } } @@ -109,10 +109,8 @@ enum OverlayPanelFactory { onHidden: @escaping @MainActor () -> Void) { self.animateDismiss(window: window, offsetX: offsetX, offsetY: offsetY, duration: duration) { - Task { @MainActor in - window.orderOut(nil) - onHidden() - } + window.orderOut(nil) + onHidden() } } diff --git a/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift index 07928e5094399..019762e8b57ce 100644 --- a/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift @@ -56,7 +56,7 @@ final class PeekabooBridgeHostCoordinator { private func startIfNeeded() async { guard self.host == nil else { return } - var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] + var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] if let teamID = Self.currentTeamID() { allowlistedTeamIDs.insert(teamID) } diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index 7ab7e8def3f77..dfae5c3bcaad3 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -15,7 +15,7 @@ actor PortGuardian { let timestamp: TimeInterval } - struct Descriptor: Sendable { + struct Descriptor { let pid: Int32 let command: String let executablePath: String? diff --git a/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift b/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift index 8840bce5569ac..8acb27324d729 100644 --- a/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift +++ b/apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift @@ -4,13 +4,13 @@ import OpenClawProtocol import OSLog import SwiftUI -struct SessionPreviewItem: Identifiable, Sendable { +struct SessionPreviewItem: Identifiable { let id: String let role: PreviewRole let text: String } -enum PreviewRole: String, Sendable { +enum PreviewRole: String { case user case assistant case tool @@ -114,7 +114,7 @@ extension SessionPreviewCache { } #endif -struct SessionMenuPreviewSnapshot: Sendable { +struct SessionMenuPreviewSnapshot { let items: [SessionPreviewItem] let status: SessionMenuPreviewView.LoadStatus } diff --git a/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift b/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift index ae9a06451044e..7679590881446 100644 --- a/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift +++ b/apps/macos/Sources/OpenClaw/TalkAudioPlayer.swift @@ -152,7 +152,7 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate { } } -struct TalkPlaybackResult: Sendable { +struct TalkPlaybackResult { let finished: Bool let interruptedAt: Double? } diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift index 8a25838997669..1763b31563027 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeChime.swift @@ -2,7 +2,7 @@ import AppKit import Foundation import OSLog -enum VoiceWakeChime: Codable, Equatable, Sendable { +enum VoiceWakeChime: Codable, Equatable { case none case system(name: String) case custom(displayName: String, bookmark: Data) diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift index 0c6ea54c90e01..57a240afc577b 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift @@ -32,7 +32,7 @@ enum VoiceWakeForwarder { } } - struct ForwardOptions: Sendable { + struct ForwardOptions { var sessionKey: String = "main" var thinking: String = "low" var deliver: Bool = true diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 61e19d9138180..cbec3e74e932a 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -16,7 +16,7 @@ private enum WebChatSwiftUILayout { static let anchorPadding: CGFloat = 8 } -struct MacGatewayChatTransport: OpenClawChatTransport, Sendable { +struct MacGatewayChatTransport: OpenClawChatTransport { func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) } diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift index 213e59b552c22..9e9d43388eb47 100644 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -374,9 +374,9 @@ public final class GatewayDiscoveryModel { if let host = gateway.serviceHost? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased(), - !host.isEmpty, - let port = gateway.servicePort, - port > 0 + !host.isEmpty, + let port = gateway.servicePort, + port > 0 { return "endpoint|\(host):\(port)" } @@ -674,7 +674,7 @@ public final class GatewayDiscoveryModel { } } -struct ResolvedGatewayService: Equatable, Sendable { +struct ResolvedGatewayService: Equatable { var txt: [String: String] var host: String? var port: Int? diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift index 60f79f7bf5332..0994c262ede97 100644 --- a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift @@ -1,7 +1,7 @@ import Foundation import OpenClawKit -struct TailscaleServeGatewayBeacon: Sendable, Equatable { +struct TailscaleServeGatewayBeacon: Equatable { var displayName: String var tailnetDns: String var host: String @@ -13,7 +13,7 @@ enum TailscaleServeGatewayDiscovery { private static let probeConcurrency = 6 private static let defaultProbeTimeoutSeconds: TimeInterval = 1.6 - struct DiscoveryContext: Sendable { + struct DiscoveryContext { var tailscaleStatus: @Sendable () async -> String? var probeHost: @Sendable (_ host: String, _ timeout: TimeInterval) async -> Bool @@ -85,13 +85,13 @@ enum TailscaleServeGatewayDiscovery { } } - private struct Candidate: Sendable { + private struct Candidate { var dnsName: String var displayName: String } private static func collectCandidates(status: TailscaleStatus) -> [Candidate] { - let selfDns = normalizeDnsName(status.selfNode?.dnsName) + let selfDns = self.normalizeDnsName(status.selfNode?.dnsName) var out: [Candidate] = [] var seen = Set() @@ -112,7 +112,7 @@ enum TailscaleServeGatewayDiscovery { out.append(Candidate( dnsName: dnsName, - displayName: displayName(hostName: node.hostName, dnsName: dnsName))) + displayName: self.displayName(hostName: node.hostName, dnsName: dnsName))) if out.count >= self.maxCandidates { break @@ -257,7 +257,7 @@ enum TailscaleServeGatewayDiscovery { operation: { while true { let message = try await task.receive() - if isConnectChallenge(message: message) { + if self.isConnectChallenge(message: message) { return true } } diff --git a/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift index fea0aca91c15f..4ec3494e93dd0 100644 --- a/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift +++ b/apps/macos/Sources/OpenClawDiscovery/WideAreaGatewayDiscovery.swift @@ -1,7 +1,7 @@ import Foundation import OpenClawKit -struct WideAreaGatewayBeacon: Sendable, Equatable { +struct WideAreaGatewayBeacon: Equatable { var instanceName: String var displayName: String var host: String @@ -19,7 +19,7 @@ enum WideAreaGatewayDiscovery { private static let defaultTimeoutSeconds: TimeInterval = 0.2 private static let nameserverProbeConcurrency = 6 - struct DiscoveryContext: Sendable { + struct DiscoveryContext { var tailscaleStatus: @Sendable () -> String? var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String? diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift index f64167000e009..1a4e76958b499 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AgentEventStoreTests.swift @@ -3,11 +3,10 @@ import OpenClawProtocol import Testing @testable import OpenClaw -@Suite @MainActor struct AgentEventStoreTests { @Test - func appendAndClear() { + func `append and clear`() { let store = AgentEventStore() #expect(store.events.isEmpty) @@ -25,7 +24,7 @@ struct AgentEventStoreTests { } @Test - func trimsToMaxEvents() { + func `trims to max events`() { let store = AgentEventStore() for i in 1...401 { store.append(ControlAgentEvent( diff --git a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift index 8794a3f22fcef..b53457135b663 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AgentWorkspaceTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct AgentWorkspaceTests { @Test - func displayPathUsesTildeForHome() { + func `display path uses tilde for home`() { let home = FileManager().homeDirectoryForCurrentUser #expect(AgentWorkspace.displayPath(for: home) == "~") @@ -14,20 +13,20 @@ struct AgentWorkspaceTests { } @Test - func resolveWorkspaceURLExpandsTilde() { + func `resolve workspace URL expands tilde`() { let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp") #expect(url.path.hasSuffix("/tmp")) } @Test - func agentsURLAppendsFilename() { + func `agents URL appends filename`() { let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true) let url = AgentWorkspace.agentsURL(workspaceURL: root) #expect(url.lastPathComponent == AgentWorkspace.agentsFilename) } @Test - func bootstrapCreatesAgentsFileWhenMissing() throws { + func `bootstrap creates agents file when missing`() throws { let tmp = FileManager().temporaryDirectory .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: tmp) } @@ -50,7 +49,7 @@ struct AgentWorkspaceTests { } @Test - func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { + func `bootstrap safety rejects non empty folder without agents`() throws { let tmp = FileManager().temporaryDirectory .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: tmp) } @@ -63,7 +62,7 @@ struct AgentWorkspaceTests { } @Test - func bootstrapSafetyAllowsExistingAgentsFile() throws { + func `bootstrap safety allows existing agents file`() throws { let tmp = FileManager().temporaryDirectory .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: tmp) } @@ -76,7 +75,7 @@ struct AgentWorkspaceTests { } @Test - func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { + func `bootstrap skips bootstrap file when workspace has content`() throws { let tmp = FileManager().temporaryDirectory .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: tmp) } @@ -91,7 +90,7 @@ struct AgentWorkspaceTests { } @Test - func needsBootstrapFalseWhenIdentityAlreadySet() throws { + func `needs bootstrap false when identity already set`() throws { let tmp = FileManager().temporaryDirectory .appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: tmp) } diff --git a/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift index 9d46ae5a9b5c9..bbca4c21e4979 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AnyCodableEncodingTests.swift @@ -3,8 +3,8 @@ import OpenClawProtocol import Testing @testable import OpenClaw -@Suite struct AnyCodableEncodingTests { - @Test func encodesSwiftArrayAndDictionaryValues() throws { +struct AnyCodableEncodingTests { + @Test func `encodes swift array and dictionary values`() throws { let payload: [String: Any] = [ "tags": ["node", "ios"], "meta": ["count": 2], @@ -19,7 +19,7 @@ import Testing #expect(obj["null"] is NSNull) } - @Test func protocolAnyCodableEncodesPrimitiveArrays() throws { + @Test func `protocol any codable encodes primitive arrays`() throws { let payload: [String: Any] = [ "items": [1, "two", NSNull(), ["ok": true]], ] diff --git a/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift index a175e5e1a0acb..7a35456018369 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift @@ -2,15 +2,15 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct AudioInputDeviceObserverTests { - @Test func hasUsableDefaultInputDeviceReturnsBool() { +struct AudioInputDeviceObserverTests { + @Test func `has usable default input device returns bool`() { // 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() { + @Test func `has usable default input device consistent with components`() { // 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() diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index 651dfeb4c15c9..6b4ad967cf5fa 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct CLIInstallerTests { - @Test func installedLocationFindsExecutable() throws { + @Test func `installed location finds executable`() throws { let fm = FileManager() let root = fm.temporaryDirectory.appendingPathComponent( "openclaw-cli-installer-\(UUID().uuidString)") diff --git a/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift b/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift index 6e978644cb49f..d77e8cd7ebb60 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CameraCaptureServiceTests.swift @@ -1,14 +1,14 @@ import Testing @testable import OpenClaw -@Suite struct CameraCaptureServiceTests { - @Test func normalizeSnapDefaults() { +struct CameraCaptureServiceTests { + @Test func `normalize snap defaults`() { let res = CameraCaptureService.normalizeSnap(maxWidth: nil, quality: nil) #expect(res.maxWidth == 1600) #expect(res.quality == 0.9) } - @Test func normalizeSnapClampsValues() { + @Test func `normalize snap clamps values`() { let low = CameraCaptureService.normalizeSnap(maxWidth: -1, quality: -10) #expect(low.maxWidth == 1600) #expect(low.quality == 0.05) diff --git a/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift b/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift index c9c3e32dd8a97..1b18f3116f704 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CameraIPCTests.swift @@ -2,8 +2,8 @@ import Foundation import OpenClawIPC import Testing -@Suite struct CameraIPCTests { - @Test func cameraSnapCodableRoundtrip() throws { +struct CameraIPCTests { + @Test func `camera snap codable roundtrip`() throws { let req: Request = .cameraSnap( facing: .front, maxWidth: 640, @@ -24,7 +24,7 @@ import Testing } } - @Test func cameraClipCodableRoundtrip() throws { + @Test func `camera clip codable roundtrip`() throws { let req: Request = .cameraClip( facing: .back, durationMs: 3000, @@ -45,7 +45,7 @@ import Testing } } - @Test func cameraClipDefaultsIncludeAudioToTrueWhenMissing() throws { + @Test func `camera clip defaults include audio to true when missing`() throws { let json = """ {"type":"cameraClip","durationMs":1234} """ diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift index 3c957161743f9..cfa1776a84627 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CanvasFileWatcherTests.swift @@ -11,7 +11,7 @@ import Testing return dir } - @Test func detectsInPlaceFileWrites() async throws { + @Test func `detects in place file writes`() async throws { let dir = try self.makeTempDir() defer { try? FileManager().removeItem(at: dir) } diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift index f2156560cd74f..a12f536a6eab0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CanvasIPCTests.swift @@ -2,8 +2,8 @@ import Foundation import OpenClawIPC import Testing -@Suite struct CanvasIPCTests { - @Test func canvasPresentCodableRoundtrip() throws { +struct CanvasIPCTests { + @Test func `canvas present codable roundtrip`() throws { let placement = CanvasPlacement(x: 10, y: 20, width: 640, height: 480) let req: Request = .canvasPresent(session: "main", path: "/index.html", placement: placement) @@ -23,7 +23,7 @@ import Testing } } - @Test func canvasPresentDecodesNilPlacementWhenMissing() throws { + @Test func `canvas present decodes nil placement when missing`() throws { let json = """ {"type":"canvasPresent","session":"s","path":"/"} """ diff --git a/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift index b5b1683f7bd78..b5f5ebcdfd2fe 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CanvasWindowSmokeTests.swift @@ -7,7 +7,7 @@ import Testing @Suite(.serialized) @MainActor struct CanvasWindowSmokeTests { - @Test func panelControllerShowsAndHides() async throws { + @Test func `panel controller shows and hides`() async throws { let root = FileManager().temporaryDirectory .appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)") try FileManager().createDirectory(at: root, withIntermediateDirectories: true) @@ -30,7 +30,7 @@ struct CanvasWindowSmokeTests { controller.close() } - @Test func windowControllerShowsAndCloses() throws { + @Test func `window controller shows and closes`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)") try FileManager().createDirectory(at: root, withIntermediateDirectories: true) diff --git a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift index ef7604729019a..4d4558353512e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift @@ -41,7 +41,7 @@ private func makeChannelsStore( @Suite(.serialized) @MainActor struct ChannelsSettingsSmokeTests { - @Test func channelsSettingsBuildsBodyWithSnapshot() { + @Test func `channels settings builds body with snapshot`() { let store = makeChannelsStore( channels: [ "whatsapp": SnapshotAnyCodable([ @@ -108,7 +108,7 @@ struct ChannelsSettingsSmokeTests { _ = view.body } - @Test func channelsSettingsBuildsBodyWithoutSnapshot() { + @Test func `channels settings builds body without snapshot`() { let store = makeChannelsStore( channels: [ "whatsapp": SnapshotAnyCodable([ diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 89fffd9dabf01..969a8ea1a5141 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -23,7 +23,7 @@ import Testing return (tmp, pnpmPath) } - @Test func prefersOpenClawBinary() throws { + @Test func `prefers open claw binary`() throws { let defaults = self.makeLocalDefaults() let tmp = try makeTempDirForTests() @@ -36,7 +36,7 @@ import Testing #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) } - @Test func fallsBackToNodeAndScript() throws { + @Test func `falls back to node and script`() throws { let defaults = self.makeLocalDefaults() let tmp = try makeTempDirForTests() @@ -63,7 +63,7 @@ import Testing } } - @Test func prefersOpenClawBinaryOverPnpm() throws { + @Test func `prefers open claw binary over pnpm`() throws { let defaults = self.makeLocalDefaults() let tmp = try makeTempDirForTests() @@ -84,7 +84,7 @@ import Testing #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"])) } - @Test func usesOpenClawBinaryWithoutNodeRuntime() throws { + @Test func `uses open claw binary without node runtime`() throws { let defaults = self.makeLocalDefaults() let tmp = try makeTempDirForTests() @@ -103,7 +103,7 @@ import Testing #expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"])) } - @Test func fallsBackToPnpm() throws { + @Test func `falls back to pnpm`() throws { let defaults = self.makeLocalDefaults() let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm() @@ -116,7 +116,7 @@ import Testing #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"])) } - @Test func pnpmKeepsExtraArgsAfterSubcommand() throws { + @Test func `pnpm keeps extra args after subcommand`() throws { let defaults = self.makeLocalDefaults() let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm() @@ -131,7 +131,7 @@ import Testing #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) } - @Test func preferredPathsStartWithProjectNodeBins() throws { + @Test func `preferred paths start with project node bins`() throws { let tmp = try makeTempDirForTests() CommandResolver.setProjectRoot(tmp.path) @@ -139,7 +139,7 @@ import Testing #expect(first == tmp.appendingPathComponent("node_modules/.bin").path) } - @Test func buildsSSHCommandForRemoteMode() { + @Test func `builds SSH command for remote mode`() { let defaults = self.makeDefaults() defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey) @@ -170,13 +170,13 @@ import Testing } } - @Test func rejectsUnsafeSSHTargets() { + @Test func `rejects unsafe SSH targets`() { #expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil) #expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil) #expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222) } - @Test func configRootLocalOverridesRemoteDefaults() throws { + @Test func `config root local overrides remote defaults`() throws { let defaults = self.makeDefaults() defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey) diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift index 50f72241dd8ee..b3ad56d71a15b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) @MainActor struct ConfigStoreTests { - @Test func loadUsesRemoteInRemoteMode() async { + @Test func `load uses remote in remote mode`() async { var localHit = false var remoteHit = false await ConfigStore._testSetOverrides(.init( @@ -20,7 +20,7 @@ struct ConfigStoreTests { #expect(result["remote"] as? Bool == true) } - @Test func loadUsesLocalInLocalMode() async { + @Test func `load uses local in local mode`() async { var localHit = false var remoteHit = false await ConfigStore._testSetOverrides(.init( @@ -36,7 +36,7 @@ struct ConfigStoreTests { #expect(result["local"] as? Bool == true) } - @Test func saveRoutesToRemoteInRemoteMode() async throws { + @Test func `save routes to remote in remote mode`() async throws { var localHit = false var remoteHit = false await ConfigStore._testSetOverrides(.init( @@ -51,7 +51,7 @@ struct ConfigStoreTests { #expect(!localHit) } - @Test func saveRoutesToLocalInLocalMode() async throws { + @Test func `save routes to local in local mode`() async throws { var localHit = false var remoteHit = false await ConfigStore._testSetOverrides(.init( diff --git a/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift b/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift index 278477448be18..bf9bd81cfb4f3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CoverageDumpTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) struct CoverageDumpTests { - @Test func periodicallyFlushCoverage() async { + @Test func `periodically flush coverage`() async { guard ProcessInfo.processInfo.environment["LLVM_PROFILE_FILE"] != nil else { return } guard let writeProfile = resolveProfileWriteFile() else { return } let deadline = Date().addingTimeInterval(4) diff --git a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift index 41baee63e568f..3e1893438cabc 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift @@ -2,10 +2,9 @@ import AppKit import Testing @testable import OpenClaw -@Suite @MainActor struct CritterIconRendererTests { - @Test func makeIconRendersExpectedSize() { + @Test func `make icon renders expected size`() { let image = CritterIconRenderer.makeIcon( blink: 0.25, legWiggle: 0.5, @@ -19,7 +18,7 @@ struct CritterIconRendererTests { #expect(image.tiffRepresentation != nil) } - @Test func makeIconRendersWithBadge() { + @Test func `make icon renders with badge`() { let image = CritterIconRenderer.makeIcon( blink: 0, legWiggle: 0, @@ -31,7 +30,7 @@ struct CritterIconRendererTests { #expect(image.tiffRepresentation != nil) } - @Test func critterStatusLabelExercisesHelpers() async { + @Test func `critter status label exercises helpers`() async { await CritterStatusLabel.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift index d0304f070b149..ff7003024e2d0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift @@ -15,17 +15,17 @@ struct CronJobEditorSmokeTests { onSave: { _ in }) } - @Test func statusPillBuildsBody() { + @Test func `status pill builds body`() { _ = StatusPill(text: "ok", tint: .green).body _ = StatusPill(text: "disabled", tint: .secondary).body } - @Test func cronJobEditorBuildsBodyForNewJob() { + @Test func `cron job editor builds body for new job`() { let view = self.makeEditor() _ = view.body } - @Test func cronJobEditorBuildsBodyForExistingJob() { + @Test func `cron job editor builds body for existing job`() { let channelsStore = ChannelsStore(isPreview: true) let job = CronJob( id: "job-1", @@ -60,12 +60,12 @@ struct CronJobEditorSmokeTests { _ = view.body } - @Test func cronJobEditorExercisesBuilders() { + @Test func `cron job editor exercises builders`() { var view = self.makeEditor() view.exerciseForTesting() } - @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() { + @Test func `cron job editor includes delete after run for at schedule`() { let view = self.makeEditor() var root: [String: Any] = [:] diff --git a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift index 5f56a4395707f..306b11d2970fa 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift @@ -2,7 +2,6 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct CronModelsTests { private func makeCronJob( name: String, @@ -26,14 +25,14 @@ struct CronModelsTests { state: state) } - @Test func scheduleAtEncodesAndDecodes() throws { + @Test func `schedule at encodes and decodes`() throws { let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z") let data = try JSONEncoder().encode(schedule) let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) #expect(decoded == schedule) } - @Test func scheduleAtDecodesLegacyAtMs() throws { + @Test func `schedule at decodes legacy at ms`() throws { let json = """ {"kind":"at","atMs":1700000000000} """ @@ -45,21 +44,21 @@ struct CronModelsTests { } } - @Test func scheduleEveryEncodesAndDecodesWithAnchor() throws { + @Test func `schedule every encodes and decodes with anchor`() throws { let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000) let data = try JSONEncoder().encode(schedule) let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) #expect(decoded == schedule) } - @Test func scheduleCronEncodesAndDecodesWithTimezone() throws { + @Test func `schedule cron encodes and decodes with timezone`() throws { let schedule = CronSchedule.cron(expr: "*/5 * * * *", tz: "Europe/Vienna") let data = try JSONEncoder().encode(schedule) let decoded = try JSONDecoder().decode(CronSchedule.self, from: data) #expect(decoded == schedule) } - @Test func payloadAgentTurnEncodesAndDecodes() throws { + @Test func `payload agent turn encodes and decodes`() throws { let payload = CronPayload.agentTurn( message: "hello", thinking: "low", @@ -73,7 +72,7 @@ struct CronModelsTests { #expect(decoded == payload) } - @Test func jobEncodesAndDecodesDeleteAfterRun() throws { + @Test func `job encodes and decodes delete after run`() throws { let job = CronJob( id: "job-1", agentId: nil, @@ -94,7 +93,7 @@ struct CronModelsTests { #expect(decoded.deleteAfterRun == true) } - @Test func scheduleDecodeRejectsUnknownKind() { + @Test func `schedule decode rejects unknown kind`() { let json = """ {"kind":"wat","at":"2026-02-03T18:00:00Z"} """ @@ -103,7 +102,7 @@ struct CronModelsTests { } } - @Test func payloadDecodeRejectsUnknownKind() { + @Test func `payload decode rejects unknown kind`() { let json = """ {"kind":"wat","text":"hello"} """ @@ -112,8 +111,8 @@ struct CronModelsTests { } } - @Test func displayNameTrimsWhitespaceAndFallsBack() { - let base = makeCronJob(name: " hello ", payloadText: "hi") + @Test func `display name trims whitespace and falls back`() { + let base = self.makeCronJob(name: " hello ", payloadText: "hi") #expect(base.displayName == "hello") var unnamed = base @@ -121,8 +120,8 @@ struct CronModelsTests { #expect(unnamed.displayName == "Untitled job") } - @Test func nextRunDateAndLastRunDateDeriveFromState() { - let job = makeCronJob( + @Test func `next run date and last run date derive from state`() { + let job = self.makeCronJob( name: "t", payloadText: "hi", state: CronJobState( @@ -136,7 +135,7 @@ struct CronModelsTests { #expect(job.lastRunDate == Date(timeIntervalSince1970: 1_700_000_050)) } - @Test func decodeCronListResponseSkipsMalformedJobs() throws { + @Test func `decode cron list response skips malformed jobs`() throws { let json = """ { "jobs": [ @@ -177,7 +176,7 @@ struct CronModelsTests { #expect(jobs.first?.id == "good") } - @Test func decodeCronRunsResponseSkipsMalformedEntries() throws { + @Test func `decode cron runs response skips malformed entries`() throws { let json = """ { "entries": [ diff --git a/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift index ee537f1b62a51..ca6d9b6454f06 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DeepLinkAgentPolicyTests.swift @@ -2,8 +2,8 @@ import OpenClawKit import Testing @testable import OpenClaw -@Suite struct DeepLinkAgentPolicyTests { - @Test func validateMessageForHandleRejectsTooLongWhenUnkeyed() { +struct DeepLinkAgentPolicyTests { + @Test func `validate message for handle rejects too long when unkeyed`() { let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: false) switch res { @@ -17,7 +17,7 @@ import Testing } } - @Test func validateMessageForHandleAllowsTooLongWhenKeyed() { + @Test func `validate message for handle allows too long when keyed`() { let msg = String(repeating: "a", count: DeepLinkAgentPolicy.maxUnkeyedConfirmChars + 1) let res = DeepLinkAgentPolicy.validateMessageForHandle(message: msg, allowUnattended: true) switch res { @@ -28,7 +28,7 @@ import Testing } } - @Test func effectiveDeliveryIgnoresDeliveryFieldsWhenUnkeyed() { + @Test func `effective delivery ignores delivery fields when unkeyed`() { let link = AgentDeepLink( message: "Hello", sessionKey: "s", @@ -44,7 +44,7 @@ import Testing #expect(res.channel == .last) } - @Test func effectiveDeliveryHonorsDeliverForDeliverableChannelsWhenKeyed() { + @Test func `effective delivery honors deliver for deliverable channels when keyed`() { let link = AgentDeepLink( message: "Hello", sessionKey: "s", @@ -60,7 +60,7 @@ import Testing #expect(res.channel == .whatsapp) } - @Test func effectiveDeliveryStillBlocksWebChatDeliveryWhenKeyed() { + @Test func `effective delivery still blocks web chat delivery when keyed`() { let link = AgentDeepLink( message: "Hello", sessionKey: "s", diff --git a/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift b/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift index 7d5f1ef679702..807dbfb60d76a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DeviceModelCatalogTests.swift @@ -1,10 +1,9 @@ import Testing @testable import OpenClaw -@Suite struct DeviceModelCatalogTests { @Test - func symbolPrefersModelIdentifierPrefixes() { + func `symbol prefers model identifier prefixes`() { #expect(DeviceModelCatalog .symbol(deviceFamily: "iPad", modelIdentifier: "iPad16,6", friendlyName: nil) == "ipad") #expect(DeviceModelCatalog @@ -12,7 +11,7 @@ struct DeviceModelCatalogTests { } @Test - func symbolUsesFriendlyNameForMacVariants() { + func `symbol uses friendly name for mac variants`() { #expect(DeviceModelCatalog.symbol( deviceFamily: "Mac", modelIdentifier: "Mac99,1", @@ -28,13 +27,13 @@ struct DeviceModelCatalogTests { } @Test - func symbolFallsBackToDeviceFamily() { + func `symbol falls back to device family`() { #expect(DeviceModelCatalog.symbol(deviceFamily: "Android", modelIdentifier: "", friendlyName: nil) == "android") #expect(DeviceModelCatalog.symbol(deviceFamily: "Linux", modelIdentifier: "", friendlyName: nil) == "cpu") } @Test - func presentationUsesBundledModelMappings() { + func `presentation uses bundled model mappings`() { let presentation = DeviceModelCatalog.presentation(deviceFamily: "iPhone", modelIdentifier: "iPhone1,1") #expect(presentation?.title == "iPhone") } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 71d979be96f77..f12b8f717dc67 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -59,21 +59,21 @@ struct ExecAllowlistTests { cwd: nil) } - @Test func matchUsesResolvedPath() { + @Test func `match uses resolved path`() { let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg") let resolution = Self.homebrewRGResolution() let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) #expect(match?.pattern == entry.pattern) } - @Test func matchIgnoresBasenamePattern() { + @Test func `match ignores basename pattern`() { let entry = ExecAllowlistEntry(pattern: "rg") let resolution = Self.homebrewRGResolution() let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) #expect(match == nil) } - @Test func matchIgnoresBasenameForRelativeExecutable() { + @Test func `match ignores basename for relative executable`() { let entry = ExecAllowlistEntry(pattern: "echo") let resolution = ExecCommandResolution( rawExecutable: "./echo", @@ -84,21 +84,21 @@ struct ExecAllowlistTests { #expect(match == nil) } - @Test func matchIsCaseInsensitive() { + @Test func `match is case insensitive`() { let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG") let resolution = Self.homebrewRGResolution() let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) #expect(match?.pattern == entry.pattern) } - @Test func matchSupportsGlobStar() { + @Test func `match supports glob star`() { let entry = ExecAllowlistEntry(pattern: "/opt/**/rg") let resolution = Self.homebrewRGResolution() let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) #expect(match?.pattern == entry.pattern) } - @Test func resolveForAllowlistSplitsShellChains() { + @Test func `resolve for allowlist splits shell chains`() { let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -110,7 +110,7 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "touch") } - @Test func resolveForAllowlistKeepsQuotedOperatorsInSingleSegment() { + @Test func `resolve for allowlist keeps quoted operators in single segment`() { let command = ["/bin/sh", "-lc", "echo \"a && b\""] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -121,7 +121,7 @@ struct ExecAllowlistTests { #expect(resolutions[0].executableName == "echo") } - @Test func resolveForAllowlistFailsClosedOnCommandSubstitution() { + @Test func `resolve for allowlist fails closed on command substitution`() { let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -131,7 +131,7 @@ struct ExecAllowlistTests { #expect(resolutions.isEmpty) } - @Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() { + @Test func `resolve for allowlist fails closed on quoted command substitution`() { let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -141,7 +141,7 @@ struct ExecAllowlistTests { #expect(resolutions.isEmpty) } - @Test func resolveForAllowlistFailsClosedOnQuotedBackticks() { + @Test func `resolve for allowlist fails closed on quoted backticks`() { let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -151,7 +151,7 @@ struct ExecAllowlistTests { #expect(resolutions.isEmpty) } - @Test func resolveForAllowlistMatchesSharedShellParserFixture() throws { + @Test func `resolve for allowlist matches shared shell parser fixture`() throws { let fixtures = try Self.loadShellParserParityCases() for fixture in fixtures { let resolutions = ExecCommandResolution.resolveForAllowlist( @@ -169,7 +169,7 @@ struct ExecAllowlistTests { } } - @Test func resolveMatchesSharedWrapperResolutionFixture() throws { + @Test func `resolve matches shared wrapper resolution fixture`() throws { let fixtures = try Self.loadWrapperResolutionParityCases() for fixture in fixtures { let resolution = ExecCommandResolution.resolve( @@ -180,7 +180,7 @@ struct ExecAllowlistTests { } } - @Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() { + @Test func `resolve for allowlist treats plain sh invocation as direct exec`() { let command = ["/bin/sh", "./script.sh"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -191,7 +191,7 @@ struct ExecAllowlistTests { #expect(resolutions[0].executableName == "sh") } - @Test func resolveForAllowlistUnwrapsEnvShellWrapperChains() { + @Test func `resolve for allowlist unwraps env shell wrapper chains`() { let command = [ "/usr/bin/env", "/bin/sh", @@ -208,7 +208,7 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "touch") } - @Test func resolveForAllowlistUnwrapsEnvToEffectiveDirectExecutable() { + @Test func `resolve for allowlist unwraps env to effective direct executable`() { let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -220,7 +220,7 @@ struct ExecAllowlistTests { #expect(resolutions[0].executableName == "printf") } - @Test func matchAllRequiresEverySegmentToMatch() { + @Test func `match all requires every segment to match`() { let first = ExecCommandResolution( rawExecutable: "echo", resolvedPath: "/usr/bin/echo", diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift index 457705f3e7863..17f9f27d2a0dc 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct ExecApprovalHelpersTests { - @Test func parseDecisionTrimsAndRejectsInvalid() { +struct ExecApprovalHelpersTests { + @Test func `parse decision trims and rejects invalid`() { #expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce) #expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways) #expect(ExecApprovalHelpers.parseDecision("deny") == .deny) @@ -11,7 +11,7 @@ import Testing #expect(ExecApprovalHelpers.parseDecision("nope") == nil) } - @Test func allowlistPatternPrefersResolution() { + @Test func `allowlist pattern prefers resolution`() { let resolved = ExecCommandResolution( rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", @@ -29,7 +29,7 @@ import Testing #expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil) } - @Test func validateAllowlistPatternReturnsReasons() { + @Test func `validate allowlist pattern returns reasons`() { #expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg")) #expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg ")) #expect(!ExecApprovalHelpers.isPathPattern("rg")) @@ -47,7 +47,7 @@ import Testing } } - @Test func requiresAskMatchesPolicy() { + @Test func `requires ask matches policy`() { let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil) #expect(ExecApprovalHelpers.requiresAsk( ask: .always, diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift index 4bc75405398e0..cd4e234ed66d9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift @@ -1,10 +1,9 @@ import Testing @testable import OpenClaw -@Suite @MainActor struct ExecApprovalsGatewayPrompterTests { - @Test func sessionMatchPrefersActiveSession() { + @Test func `session match prefers active session`() { let matches = ExecApprovalsGatewayPrompter._testShouldPresent( mode: .remote, activeSession: " main ", @@ -20,7 +19,7 @@ struct ExecApprovalsGatewayPrompterTests { #expect(!mismatched) } - @Test func sessionFallbackUsesRecentActivity() { + @Test func `session fallback uses recent activity`() { let recent = ExecApprovalsGatewayPrompter._testShouldPresent( mode: .remote, activeSession: nil, @@ -38,7 +37,7 @@ struct ExecApprovalsGatewayPrompterTests { #expect(!stale) } - @Test func defaultBehaviorMatchesMode() { + @Test func `default behavior matches mode`() { let local = ExecApprovalsGatewayPrompter._testShouldPresent( mode: .local, activeSession: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketPathGuardTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketPathGuardTests.swift index 64194a0dd9709..a52b72683e8d4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketPathGuardTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsSocketPathGuardTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) struct ExecApprovalsSocketPathGuardTests { @Test - func hardenParentDirectoryCreatesDirectoryWith0700Permissions() throws { + func `harden parent directory creates directory with0700 permissions`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: root) } @@ -24,7 +24,7 @@ struct ExecApprovalsSocketPathGuardTests { } @Test - func removeExistingSocketRejectsSymlinkPath() throws { + func `remove existing socket rejects symlink path`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: root) } @@ -50,7 +50,7 @@ struct ExecApprovalsSocketPathGuardTests { } @Test - func removeExistingSocketRejectsRegularFilePath() throws { + func `remove existing socket rejects regular file path`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: root) } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index 42dcf106d1e33..480b4cd919496 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -17,8 +17,8 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func ensureFileSkipsRewriteWhenUnchanged() async throws { - try await self.withTempStateDir { stateDir in + func `ensure file skips rewrite when unchanged`() async throws { + try await self.withTempStateDir { _ in _ = ExecApprovalsStore.ensureFile() let url = ExecApprovalsStore.fileURL() let firstWriteDate = try Self.modificationDate(at: url) @@ -32,7 +32,7 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func updateAllowlistReportsRejectedBasenamePattern() async throws { + func `update allowlist reports rejected basename pattern`() async throws { try await self.withTempStateDir { _ in let rejected = ExecApprovalsStore.updateAllowlist( agentId: "main", @@ -50,7 +50,7 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws { + func `update allowlist migrates legacy pattern from resolved path`() async throws { try await self.withTempStateDir { _ in let rejected = ExecApprovalsStore.updateAllowlist( agentId: "main", @@ -69,7 +69,7 @@ struct ExecApprovalsStoreRefactorTests { } @Test - func ensureFileHardensStateDirectoryPermissions() async throws { + func `ensure file hardens state directory permissions`() async throws { try await self.withTempStateDir { stateDir in try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true) try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path) diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift index 152e3807250aa..c9772a5d51203 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -3,7 +3,7 @@ import Testing @testable import OpenClaw struct ExecHostRequestEvaluatorTests { - @Test func validateRequestRejectsEmptyCommand() { + @Test func `validate request rejects empty command`() { let request = ExecHostRequest( command: [], rawCommand: nil, @@ -23,7 +23,7 @@ struct ExecHostRequestEvaluatorTests { } } - @Test func evaluateRequiresPromptOnAllowlistMissWithoutDecision() { + @Test func `evaluate requires prompt on allowlist miss without decision`() { let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: nil) switch decision { @@ -36,7 +36,7 @@ struct ExecHostRequestEvaluatorTests { } } - @Test func evaluateAllowsAllowOnceDecisionOnAllowlistMiss() { + @Test func `evaluate allows allow once decision on allowlist miss`() { let context = Self.makeContext(security: .allowlist, ask: .onMiss, allowlistSatisfied: false, skillAllow: false) let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .allowOnce) switch decision { @@ -49,7 +49,7 @@ struct ExecHostRequestEvaluatorTests { } } - @Test func evaluateDeniesOnExplicitDenyDecision() { + @Test func `evaluate denies on explicit deny decision`() { let context = Self.makeContext(security: .full, ask: .off, allowlistSatisfied: true, skillAllow: false) let decision = ExecHostRequestEvaluator.evaluate(context: context, approvalDecision: .deny) switch decision { diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift index 701ff737d436b..64dbb335807e3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -20,7 +20,7 @@ private struct SystemRunCommandContractExpected: Decodable { } struct ExecSystemRunCommandValidatorTests { - @Test func matchesSharedSystemRunCommandContractFixture() throws { + @Test func `matches shared system run command contract fixture`() throws { for entry in try Self.loadContractCases() { let result = ExecSystemRunCommandValidator.resolve(command: entry.command, rawCommand: entry.rawCommand) diff --git a/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift b/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift index a6836aaa0811e..3ce4221728794 100644 --- a/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/FileHandleLegacyAPIGuardTests.swift @@ -1,8 +1,8 @@ import Foundation import Testing -@Suite struct FileHandleLegacyAPIGuardTests { - @Test func sourcesAvoidLegacyNonThrowingFileHandleReadAPIs() throws { +struct FileHandleLegacyAPIGuardTests { + @Test func `sources avoid legacy non throwing file handle read AP is`() throws { let testFile = URL(fileURLWithPath: #filePath) let packageRoot = testFile .deletingLastPathComponent() // OpenClawIPCTests diff --git a/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift b/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift index 3b679a7d58678..5fb2e1c86ded3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/FileHandleSafeReadTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct FileHandleSafeReadTests { - @Test func readToEndSafelyReturnsEmptyForClosedHandle() { +struct FileHandleSafeReadTests { + @Test func `read to end safely returns empty for closed handle`() { let pipe = Pipe() let handle = pipe.fileHandleForReading try? handle.close() @@ -12,7 +12,7 @@ import Testing #expect(data.isEmpty) } - @Test func readSafelyUpToCountReturnsEmptyForClosedHandle() { + @Test func `read safely up to count returns empty for closed handle`() { let pipe = Pipe() let handle = pipe.fileHandleForReading try? handle.close() @@ -21,7 +21,7 @@ import Testing #expect(data.isEmpty) } - @Test func readToEndSafelyReadsPipeContents() { + @Test func `read to end safely reads pipe contents`() { let pipe = Pipe() let writeHandle = pipe.fileHandleForWriting writeHandle.write(Data("hello".utf8)) @@ -31,7 +31,7 @@ import Testing #expect(String(data: data, encoding: .utf8) == "hello") } - @Test func readSafelyUpToCountReadsIncrementally() { + @Test func `read safely up to count reads incrementally`() { let pipe = Pipe() let writeHandle = pipe.fileHandleForWriting writeHandle.write(Data("hello world".utf8)) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift index 18972a23bbcb1..9a80d9e6b5e49 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayAgentChannelTests.swift @@ -1,13 +1,13 @@ import Testing @testable import OpenClaw -@Suite struct GatewayAgentChannelTests { - @Test func shouldDeliverBlocksWebChat() { +struct GatewayAgentChannelTests { + @Test func `should deliver blocks web chat`() { #expect(GatewayAgentChannel.webchat.shouldDeliver(true) == false) #expect(GatewayAgentChannel.webchat.shouldDeliver(false) == false) } - @Test func shouldDeliverAllowsLastAndProviderChannels() { + @Test func `should deliver allows last and provider channels`() { #expect(GatewayAgentChannel.last.shouldDeliver(true) == true) #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) @@ -16,7 +16,7 @@ import Testing #expect(GatewayAgentChannel.last.shouldDeliver(false) == false) } - @Test func initRawNormalizesAndFallsBackToLast() { + @Test func `init raw normalizes and falls back to last`() { #expect(GatewayAgentChannel(raw: nil) == .last) #expect(GatewayAgentChannel(raw: " ") == .last) #expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift index f2fea5fc458a4..552f029b5f210 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayAutostartPolicyTests.swift @@ -3,14 +3,14 @@ import Testing @Suite(.serialized) struct GatewayAutostartPolicyTests { - @Test func startsGatewayOnlyWhenLocalAndNotPaused() { + @Test func `starts gateway only when local and not paused`() { #expect(GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: false)) #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: true)) #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .remote, paused: false)) #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false)) } - @Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() { + @Test func `ensures launch agent when local and not attach only`() { #expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent( mode: .local, paused: false)) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift index f1d87fdac5f92..7ad66edef3ce8 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift @@ -4,7 +4,7 @@ import os import Testing @testable import OpenClaw -@Suite struct GatewayConnectionTests { +struct GatewayConnectionTests { private func makeConnection( session: GatewayTestWebSocketSession, token: String? = nil) throws -> (GatewayConnection, ConfigSource) @@ -56,7 +56,7 @@ import Testing } } - @Test func requestReusesSingleWebSocketForSameConfig() async throws { + @Test func `request reuses single web socket for same config`() async throws { let session = self.makeSession() let (conn, _) = try self.makeConnection(session: session) @@ -68,7 +68,7 @@ import Testing #expect(session.snapshotCancelCount() == 0) } - @Test func requestReconfiguresAndCancelsOnTokenChange() async throws { + @Test func `request reconfigures and cancels on token change`() async throws { let session = self.makeSession() let (conn, cfg) = try self.makeConnection(session: session, token: "a") @@ -81,7 +81,7 @@ import Testing #expect(session.snapshotCancelCount() == 1) } - @Test func concurrentRequestsStillUseSingleWebSocket() async throws { + @Test func `concurrent requests still use single web socket`() async throws { let session = self.makeSession(helloDelayMs: 150) let (conn, _) = try self.makeConnection(session: session) @@ -92,7 +92,7 @@ import Testing #expect(session.snapshotMakeCount() == 1) } - @Test func subscribeReplaysLatestSnapshot() async throws { + @Test func `subscribe replays latest snapshot`() async throws { let session = self.makeSession() let (conn, _) = try self.makeConnection(session: session) @@ -109,7 +109,7 @@ import Testing #expect(snap.type == "hello-ok") } - @Test func subscribeEmitsSeqGapBeforeEvent() async throws { + @Test func `subscribe emits seq gap before event`() async throws { let session = self.makeSession() let (conn, _) = try self.makeConnection(session: session) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index ae0550aa6a768..8d37faa511e27 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -3,7 +3,7 @@ import OpenClawKit import Testing @testable import OpenClaw -@Suite struct GatewayChannelConnectTests { +struct GatewayChannelConnectTests { private enum FakeResponse { case helloOk(delayMs: Int) case invalid(delayMs: Int) @@ -34,7 +34,7 @@ import Testing }) } - @Test func concurrentConnectIsSingleFlightOnSuccess() async throws { + @Test func `concurrent connect is single flight on success`() async throws { let session = self.makeSession(response: .helloOk(delayMs: 200)) let channel = try GatewayChannelActor( url: #require(URL(string: "ws://example.invalid")), @@ -50,7 +50,7 @@ import Testing #expect(session.snapshotMakeCount() == 1) } - @Test func concurrentConnectSharesFailure() async throws { + @Test func `concurrent connect shares failure`() async throws { let session = self.makeSession(response: .invalid(delayMs: 200)) let channel = try GatewayChannelActor( url: #require(URL(string: "ws://example.invalid")), diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift index 95095177300bc..c28b891729559 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift @@ -3,7 +3,7 @@ import OpenClawKit import Testing @testable import OpenClaw -@Suite struct GatewayChannelRequestTests { +struct GatewayChannelRequestTests { private func makeSession(requestSendDelayMs: Int) -> GatewayTestWebSocketSession { GatewayTestWebSocketSession( taskFactory: { @@ -16,7 +16,7 @@ import Testing }) } - @Test func requestTimeoutThenSendFailureDoesNotDoubleResume() async throws { + @Test func `request timeout then send failure does not double resume`() async throws { let session = self.makeSession(requestSendDelayMs: 100) let channel = try GatewayChannelActor( url: #require(URL(string: "ws://example.invalid")), diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift index ee2d95f3ba470..8904030b9e338 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift @@ -3,8 +3,8 @@ import OpenClawKit import Testing @testable import OpenClaw -@Suite struct GatewayChannelShutdownTests { - @Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws { +struct GatewayChannelShutdownTests { + @Test func `shutdown prevents reconnect loop from receive failure`() async throws { let session = GatewayTestWebSocketSession() let channel = try GatewayChannelActor( url: #require(URL(string: "ws://example.invalid")), diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index c9ec6c8bab7b7..9dfc1858ae980 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -39,14 +39,14 @@ private func makeTestGatewayConnection() -> GatewayConnection { } @Suite(.serialized) struct GatewayConnectionControlTests { - @Test func statusFailsWhenProcessMissing() async { + @Test func `status fails when process missing`() async { let connection = makeTestGatewayConnection() let result = await connection.status() #expect(result.ok == false) #expect(result.error != nil) } - @Test func rejectEmptyMessage() async { + @Test func `reject empty message`() async { let connection = makeTestGatewayConnection() let result = await connection.sendAgent( message: "", diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift index de62fa6978739..6a57d5c3eed27 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift @@ -3,7 +3,6 @@ import OpenClawDiscovery import Testing @testable import OpenClaw -@Suite struct GatewayDiscoveryHelpersTests { private func makeGateway( serviceHost: String?, @@ -41,23 +40,23 @@ struct GatewayDiscoveryHelpersTests { #expect(parsed?.port == port) } - @Test func sshTargetUsesResolvedServiceHostOnly() { + @Test func `ssh target uses resolved service host only`() { let gateway = self.makeGateway( serviceHost: "resolved.example.ts.net", servicePort: 18789, sshPort: 2201) - assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201) + self.assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201) } - @Test func sshTargetAllowsMissingResolvedServicePort() { + @Test func `ssh target allows missing resolved service port`() { let gateway = self.makeGateway( serviceHost: "resolved.example.ts.net", servicePort: nil, sshPort: 2201) - assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201) + self.assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201) } - @Test func sshTargetRejectsTxtOnlyGateways() { + @Test func `ssh target rejects txt only gateways`() { let gateway = self.makeGateway( serviceHost: nil, servicePort: nil, @@ -68,7 +67,7 @@ struct GatewayDiscoveryHelpersTests { #expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil) } - @Test func directUrlUsesResolvedServiceEndpointOnly() { + @Test func `direct url uses resolved service endpoint only`() { let tlsGateway = self.makeGateway( serviceHost: "resolved.example.ts.net", servicePort: 443) @@ -85,7 +84,7 @@ struct GatewayDiscoveryHelpersTests { #expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789") } - @Test func directUrlRejectsTxtOnlyFallback() { + @Test func `direct url rejects txt only fallback`() { let gateway = self.makeGateway( serviceHost: nil, servicePort: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift index bbafce58c6648..96992b324eb4e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift @@ -1,10 +1,9 @@ -@testable import OpenClawDiscovery import Testing +@testable import OpenClawDiscovery -@Suite @MainActor struct GatewayDiscoveryModelTests { - @Test func localGatewayMatchesLanHost() { + @Test func `local gateway matches lan host`() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: ["studio"], displayTokens: []) @@ -16,7 +15,7 @@ struct GatewayDiscoveryModelTests { local: local)) } - @Test func localGatewayMatchesTailnetDns() { + @Test func `local gateway matches tailnet dns`() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: ["studio"], displayTokens: []) @@ -28,7 +27,7 @@ struct GatewayDiscoveryModelTests { local: local)) } - @Test func localGatewayMatchesDisplayName() { + @Test func `local gateway matches display name`() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: [], displayTokens: ["peter's mac studio"]) @@ -40,7 +39,7 @@ struct GatewayDiscoveryModelTests { local: local)) } - @Test func remoteGatewayDoesNotMatch() { + @Test func `remote gateway does not match`() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: ["studio"], displayTokens: ["peter's mac studio"]) @@ -52,7 +51,7 @@ struct GatewayDiscoveryModelTests { local: local)) } - @Test func localGatewayMatchesServiceName() { + @Test func `local gateway matches service name`() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: ["studio"], displayTokens: []) @@ -64,7 +63,7 @@ struct GatewayDiscoveryModelTests { local: local)) } - @Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() { + @Test func `service name does not false positive on substring host token`() { let local = GatewayDiscoveryModel.LocalIdentity( hostTokens: ["steipete"], displayTokens: []) @@ -82,7 +81,7 @@ struct GatewayDiscoveryModelTests { local: local)) } - @Test func parsesGatewayTXTFields() { + @Test func `parses gateway TXT fields`() { let parsed = GatewayDiscoveryModel.parseGatewayTXT([ "lanHost": " studio.local ", "tailnetDns": " peters-mac-studio-1.ts.net ", @@ -97,7 +96,7 @@ struct GatewayDiscoveryModelTests { #expect(parsed.cliPath == "/opt/openclaw") } - @Test func parsesGatewayTXTDefaults() { + @Test func `parses gateway TXT defaults`() { let parsed = GatewayDiscoveryModel.parseGatewayTXT([ "lanHost": " ", "tailnetDns": "\n", @@ -111,7 +110,7 @@ struct GatewayDiscoveryModelTests { #expect(parsed.cliPath == nil) } - @Test func buildsSSHTarget() { + @Test func `builds SSH target`() { #expect(GatewayDiscoveryModel.buildSSHTarget( user: "peter", host: "studio.local", @@ -122,7 +121,7 @@ struct GatewayDiscoveryModelTests { port: 2201) == "peter@studio.local:2201") } - @Test func dedupeKeyPrefersResolvedEndpointAcrossSources() { + @Test func `dedupe key prefers resolved endpoint across sources`() { let wideArea = GatewayDiscoveryModel.DiscoveredGateway( displayName: "Gateway", serviceHost: "gateway-host.tailnet-example.ts.net", @@ -151,7 +150,7 @@ struct GatewayDiscoveryModelTests { #expect(GatewayDiscoveryModel.dedupeKey(for: wideArea) == GatewayDiscoveryModel.dedupeKey(for: serve)) } - @Test func dedupeKeyFallsBackToStableIDWithoutEndpoint() { + @Test func `dedupe key falls back to stable ID without endpoint`() { let unresolved = GatewayDiscoveryModel.DiscoveredGateway( displayName: "Gateway", serviceHost: nil, @@ -165,6 +164,7 @@ struct GatewayDiscoveryModelTests { debugID: "serve", isLocal: false) - #expect(GatewayDiscoveryModel.dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net") + #expect(GatewayDiscoveryModel + .dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net") } } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index aab33248b2538..39ca29b33d526 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct GatewayEndpointStoreTests { +struct GatewayEndpointStoreTests { private func makeLaunchAgentSnapshot( env: [String: String], token: String?, @@ -26,7 +26,7 @@ import Testing return defaults } - @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() { + @Test func `resolve gateway token prefers env and falls back to launchd`() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"], token: "launchd-token", @@ -47,7 +47,7 @@ import Testing #expect(fallbackToken == "launchd-token") } - @Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() { + @Test func `resolve gateway token ignores launchd in remote mode`() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"], token: "launchd-token", @@ -61,7 +61,7 @@ import Testing #expect(token == nil) } - @Test func resolveGatewayPasswordFallsBackToLaunchd() { + @Test func `resolve gateway password falls back to launchd`() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], token: nil, @@ -75,7 +75,7 @@ import Testing #expect(password == "launchd-pass") } - @Test func connectionModeResolverPrefersConfigModeOverDefaults() { + @Test func `connection mode resolver prefers config mode over defaults`() { let defaults = self.makeDefaults() defaults.set("remote", forKey: connectionModeKey) @@ -89,7 +89,7 @@ import Testing #expect(resolved.mode == .local) } - @Test func connectionModeResolverTrimsConfigMode() { + @Test func `connection mode resolver trims config mode`() { let defaults = self.makeDefaults() defaults.set("local", forKey: connectionModeKey) @@ -103,7 +103,7 @@ import Testing #expect(resolved.mode == .remote) } - @Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() { + @Test func `connection mode resolver falls back to defaults when missing config`() { let defaults = self.makeDefaults() defaults.set("remote", forKey: connectionModeKey) @@ -111,7 +111,7 @@ import Testing #expect(resolved.mode == .remote) } - @Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() { + @Test func `connection mode resolver falls back to defaults on unknown config`() { let defaults = self.makeDefaults() defaults.set("local", forKey: connectionModeKey) @@ -125,7 +125,7 @@ import Testing #expect(resolved.mode == .local) } - @Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() { + @Test func `connection mode resolver prefers remote URL when mode missing`() { let defaults = self.makeDefaults() defaults.set("local", forKey: connectionModeKey) @@ -141,35 +141,35 @@ import Testing #expect(resolved.mode == .remote) } - @Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() { + @Test func `resolve local gateway host uses loopback for auto even with tailnet`() { let host = GatewayEndpointStore._testResolveLocalGatewayHost( bindMode: "auto", tailscaleIP: "100.64.1.2") #expect(host == "127.0.0.1") } - @Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() { + @Test func `resolve local gateway host uses loopback for auto without tailnet`() { let host = GatewayEndpointStore._testResolveLocalGatewayHost( bindMode: "auto", tailscaleIP: nil) #expect(host == "127.0.0.1") } - @Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() { + @Test func `resolve local gateway host prefers tailnet for tailnet mode`() { let host = GatewayEndpointStore._testResolveLocalGatewayHost( bindMode: "tailnet", tailscaleIP: "100.64.1.5") #expect(host == "100.64.1.5") } - @Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() { + @Test func `resolve local gateway host falls back to loopback for tailnet mode`() { let host = GatewayEndpointStore._testResolveLocalGatewayHost( bindMode: "tailnet", tailscaleIP: nil) #expect(host == "127.0.0.1") } - @Test func resolveLocalGatewayHostUsesCustomBindHost() { + @Test func `resolve local gateway host uses custom bind host`() { let host = GatewayEndpointStore._testResolveLocalGatewayHost( bindMode: "custom", tailscaleIP: "100.64.1.9", @@ -177,7 +177,7 @@ import Testing #expect(host == "192.168.1.10") } - @Test func localConfigUsesLocalGatewayAuthAndHostResolution() throws { + @Test func `local config uses local gateway auth and host resolution`() { let snapshot = self.makeLaunchAgentSnapshot( env: [:], token: "launchd-token", @@ -204,7 +204,7 @@ import Testing #expect(config.password == "launchd-pass") } - @Test func dashboardURLUsesLocalBasePathInLocalMode() throws { + @Test func `dashboard URL uses local base path in local mode`() throws { let config: GatewayConnection.Config = try ( url: #require(URL(string: "ws://127.0.0.1:18789")), token: nil, @@ -217,7 +217,7 @@ import Testing #expect(url.absoluteString == "http://127.0.0.1:18789/control/") } - @Test func dashboardURLSkipsLocalBasePathInRemoteMode() throws { + @Test func `dashboard URL skips local base path in remote mode`() throws { let config: GatewayConnection.Config = try ( url: #require(URL(string: "ws://gateway.example:18789")), token: nil, @@ -230,7 +230,7 @@ import Testing #expect(url.absoluteString == "http://gateway.example:18789/") } - @Test func dashboardURLPrefersPathFromConfigURL() throws { + @Test func `dashboard URL prefers path from config URL`() throws { let config: GatewayConnection.Config = try ( url: #require(URL(string: "wss://gateway.example:443/remote-ui")), token: nil, @@ -243,7 +243,7 @@ import Testing #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") } - @Test func dashboardURLUsesFragmentTokenAndOmitsPassword() throws { + @Test func `dashboard URL uses fragment token and omits password`() throws { let config: GatewayConnection.Config = try ( url: #require(URL(string: "ws://127.0.0.1:18789")), token: "abc123", @@ -257,18 +257,18 @@ import Testing #expect(url.query == nil) } - @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { + @Test func `normalize gateway url adds default port for loopback ws`() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") #expect(url?.port == 18789) #expect(url?.absoluteString == "ws://127.0.0.1:18789") } - @Test func normalizeGatewayUrlRejectsNonLoopbackWs() { + @Test func `normalize gateway url rejects non loopback ws`() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") #expect(url == nil) } - @Test func normalizeGatewayUrlRejectsPrefixBypassLoopbackHost() { + @Test func `normalize gateway url rejects prefix bypass loopback host`() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example") #expect(url == nil) } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift index 32dcbb737f9de..8d4e2004bcc56 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct GatewayEnvironmentTests { - @Test func semverParsesCommonForms() { +struct GatewayEnvironmentTests { + @Test func `semver parses common forms`() { #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) #expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3)) #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) @@ -21,7 +21,7 @@ import Testing #expect(Semver.parse("1.2.x") == nil) } - @Test func semverCompatibilityRequiresSameMajorAndNotOlder() { + @Test func `semver compatibility requires same major and not older`() { let required = Semver(major: 2, minor: 1, patch: 0) #expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required)) #expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required)) @@ -31,7 +31,7 @@ import Testing #expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false) } - @Test func gatewayPortDefaultsAndRespectsOverride() async { + @Test func `gateway port defaults and respects override`() async { let configPath = TestIsolation.tempConfigPath() await TestIsolation.withIsolatedState( env: ["OPENCLAW_CONFIG_PATH": configPath], @@ -46,7 +46,7 @@ import Testing } } - @Test func expectedGatewayVersionFromStringUsesParser() { + @Test func `expected gateway version from string uses parser`() { #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) #expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver( major: 2026, diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift index fe8b6bc34b419..ec1094246dfb3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayFrameDecodeTests.swift @@ -2,8 +2,8 @@ import Foundation import OpenClawProtocol import Testing -@Suite struct GatewayFrameDecodeTests { - @Test func decodesEventFrameWithAnyCodablePayload() throws { +struct GatewayFrameDecodeTests { + @Test func `decodes event frame with any codable payload`() throws { let json = """ { "type": "event", @@ -29,7 +29,7 @@ import Testing #expect(evt.seq == 7) } - @Test func decodesRequestFrameWithNestedParams() throws { + @Test func `decodes request frame with nested params`() throws { let json = """ { "type": "req", @@ -68,7 +68,7 @@ import Testing #expect(meta?["count"]?.value as? Int == 2) } - @Test func decodesUnknownFrameAndPreservesRaw() throws { + @Test func `decodes unknown frame and preserves raw`() throws { let json = """ { "type": "made-up", diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift index 685db8185fcf3..f64eebdbc6a03 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct GatewayLaunchAgentManagerTests { - @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws { +struct GatewayLaunchAgentManagerTests { + @Test func `launch agent plist snapshot parses args and env`() throws { let url = FileManager().temporaryDirectory .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") let plist: [String: Any] = [ @@ -24,7 +24,7 @@ import Testing #expect(snapshot.password == "pw") } - @Test func launchAgentPlistSnapshotAllowsMissingBind() throws { + @Test func `launch agent plist snapshot allows missing bind`() throws { let url = FileManager().temporaryDirectory .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") let plist: [String: Any] = [ diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 9ce0688177795..78c0116f73c54 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -6,7 +6,7 @@ import Testing @Suite(.serialized) @MainActor struct GatewayProcessManagerTests { - @Test func clearsLastFailureWhenHealthSucceeds() async throws { + @Test func `clears last failure when health succeeds`() async throws { let session = GatewayTestWebSocketSession( taskFactory: { GatewayTestWebSocketTask( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift index bb5d7c12d7a90..8af4ccf690517 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -83,9 +83,9 @@ enum GatewayWebSocketTestSupport { } } -private extension NSLock { +extension NSLock { @inline(__always) - func withLock(_ body: () throws -> T) rethrows -> T { + fileprivate func withLock(_ body: () throws -> T) rethrows -> T { self.lock(); defer { self.unlock() } return try body() } @@ -129,7 +129,10 @@ final class GatewayTestWebSocketTask: WebSocketTasking, @unchecked Sendable { func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { _ = (closeCode, reason) - let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + let handler = self.lock.withLock { () -> (@Sendable (Result< + URLSessionWebSocketTask.Message, + Error, + >) -> Void)? in self._state = .canceling self.cancelCount += 1 defer { self.pendingReceiveHandler = nil } diff --git a/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift b/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift index 44e2598e6a6b1..e492928e2a143 100644 --- a/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/HealthDecodeTests.swift @@ -2,13 +2,13 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct HealthDecodeTests { +struct HealthDecodeTests { private let sampleJSON: String = // minimal but complete payload """ {"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} """ - @Test func decodesCleanJSON() { + @Test func `decodes clean JSON`() { let data = Data(sampleJSON.utf8) let snap = decodeHealthSnapshot(from: data) @@ -16,14 +16,14 @@ import Testing #expect(snap?.sessions.count == 1) } - @Test func decodesWithLeadingNoise() { + @Test func `decodes with leading noise`() { let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer" let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) #expect(snap?.channels["telegram"]?.probe?.elapsedMs == 800) } - @Test func failsWithoutBraces() { + @Test func `fails without braces`() { let data = Data("no json here".utf8) let snap = decodeHealthSnapshot(from: data) diff --git a/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift b/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift index 8862a8d63b7de..05202e536541c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/HealthStoreStateTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct HealthStoreStateTests { - @Test @MainActor func linkedChannelProbeFailureDegradesState() { +struct HealthStoreStateTests { + @Test @MainActor func `linked channel probe failure degrades state`() { let snap = HealthSnapshot( ok: true, ts: 0, diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift index 7ee15107f40dc..1e9da910b2a12 100644 --- a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -2,7 +2,7 @@ import Testing @testable import OpenClaw struct HostEnvSanitizerTests { - @Test func sanitizeBlocksShellTraceVariables() { + @Test func `sanitize blocks shell trace variables`() { let env = HostEnvSanitizer.sanitize(overrides: [ "SHELLOPTS": "xtrace", "PS4": "$(touch /tmp/pwned)", @@ -13,7 +13,7 @@ struct HostEnvSanitizerTests { #expect(env["OPENCLAW_TEST"] == "1") } - @Test func sanitizeShellWrapperAllowsOnlyExplicitOverrideKeys() { + @Test func `sanitize shell wrapper allows only explicit override keys`() { let env = HostEnvSanitizer.sanitize( overrides: [ "LANG": "C", @@ -29,7 +29,7 @@ struct HostEnvSanitizerTests { #expect(env["PS4"] == nil) } - @Test func sanitizeNonShellWrapperKeepsRegularOverrides() { + @Test func `sanitize non shell wrapper keeps regular overrides`() { let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) #expect(env["OPENCLAW_TOKEN"] == "secret") } diff --git a/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift index eff3ee6d814e1..a6c5d5ed1e3da 100644 --- a/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct HoverHUDControllerTests { - @Test func hoverHUDControllerPresentsAndDismisses() async { + @Test func `hover HUD controller presents and dismisses`() async { let controller = HoverHUDController() controller.setSuppressed(false) diff --git a/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift index c43982ee82bfd..ab7a3c1db68ae 100644 --- a/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/InstancesSettingsSmokeTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) @MainActor struct InstancesSettingsSmokeTests { - @Test func instancesSettingsBuildsBodyWithMultipleInstances() { + @Test func `instances settings builds body with multiple instances`() { let store = InstancesStore(isPreview: true) store.statusMessage = "Loaded" store.instances = [ @@ -53,7 +53,7 @@ struct InstancesSettingsSmokeTests { _ = view.body } - @Test func instancesSettingsExercisesHelpers() { + @Test func `instances settings exercises helpers`() { InstancesSettings.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift index f148c35fb21e0..0123848b04dc2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/InstancesStoreTests.swift @@ -2,10 +2,10 @@ import OpenClawProtocol import Testing @testable import OpenClaw -@Suite struct InstancesStoreTests { +struct InstancesStoreTests { @Test @MainActor - func presenceEventPayloadDecodesViaJSONEncoder() { + func `presence event payload decodes via JSON encoder`() { // Build a payload that mirrors the gateway's presence event shape: // { "presence": [ PresenceEntry ] } let entry: [String: OpenClawProtocol.AnyCodable] = [ diff --git a/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift index 69bcbd2efcc0a..f37542416d2a1 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LogLocatorTests.swift @@ -3,8 +3,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct LogLocatorTests { - @Test func launchdGatewayLogPathEnsuresTmpDirExists() { +struct LogLocatorTests { + @Test func `launchd gateway log path ensures tmp dir exists`() { let fm = FileManager() let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let logDir = baseDir.appendingPathComponent("openclaw-tests-\(UUID().uuidString)") diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift index 78d4a5a34f695..c8928978f74a4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift @@ -8,7 +8,7 @@ import Testing struct LowCoverageHelperTests { private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable - @Test func anyCodableHelperAccessors() throws { + @Test func `any codable helper accessors`() throws { let payload: [String: ProtoAnyCodable] = [ "title": ProtoAnyCodable("Hello"), "flag": ProtoAnyCodable(true), @@ -28,7 +28,7 @@ struct LowCoverageHelperTests { #expect((foundation?["title"] as? String) == "Hello") } - @Test func attributedStringStripsForegroundColor() { + @Test func `attributed string strips foreground color`() { let text = NSMutableAttributedString(string: "Test") text.addAttribute(.foregroundColor, value: NSColor.red, range: NSRange(location: 0, length: 4)) let stripped = text.strippingForegroundColor() @@ -36,29 +36,29 @@ struct LowCoverageHelperTests { #expect(color == nil) } - @Test func viewMetricsReduceWidth() { + @Test func `view metrics reduce width`() { let value = ViewMetricsTesting.reduceWidth(current: 120, next: 180) #expect(value == 180) } - @Test func shellExecutorHandlesEmptyCommand() async { + @Test func `shell executor handles empty command`() async { let result = await ShellExecutor.runDetailed(command: [], cwd: nil, env: nil, timeout: nil) #expect(result.success == false) #expect(result.errorMessage != nil) } - @Test func shellExecutorRunsCommand() async { + @Test func `shell executor runs command`() async { let result = await ShellExecutor.runDetailed(command: ["/bin/echo", "ok"], cwd: nil, env: nil, timeout: 2) #expect(result.success == true) #expect(result.stdout.contains("ok") || result.stderr.contains("ok")) } - @Test func shellExecutorTimesOut() async { + @Test func `shell executor times out`() async { let result = await ShellExecutor.runDetailed(command: ["/bin/sleep", "1"], cwd: nil, env: nil, timeout: 0.05) #expect(result.timedOut == true) } - @Test func shellExecutorDrainsStdoutAndStderr() async { + @Test func `shell executor drains stdout and stderr`() async { let script = """ i=0 while [ $i -lt 2000 ]; do @@ -77,7 +77,7 @@ struct LowCoverageHelperTests { #expect(result.stderr.contains("stderr-1999")) } - @Test func nodeInfoCodableRoundTrip() throws { + @Test func `node info codable round trip`() throws { let info = NodeInfo( nodeId: "node-1", displayName: "Node One", @@ -100,7 +100,7 @@ struct LowCoverageHelperTests { #expect(decoded.isConnected == false) } - @Test @MainActor func presenceReporterHelpers() { + @Test @MainActor func `presence reporter helpers`() { let summary = PresenceReporter._testComposePresenceSummary(mode: "local", reason: "test") #expect(summary.contains("mode local")) #expect(!PresenceReporter._testAppVersionString().isEmpty) @@ -109,7 +109,7 @@ struct LowCoverageHelperTests { _ = PresenceReporter._testPrimaryIPv4Address() } - @Test func portGuardianParsesListenersAndBuildsReports() { + @Test func `port guardian parses listeners and builds reports`() { let output = """ p123 cnode @@ -139,7 +139,7 @@ struct LowCoverageHelperTests { #expect(emptyReport.summary.contains("Nothing is listening")) } - @Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws { + @Test @MainActor func `canvas scheme handler resolves files and errors`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager().removeItem(at: root) } @@ -168,7 +168,7 @@ struct LowCoverageHelperTests { #expect(handler._testTextEncodingName(for: "application/octet-stream") == nil) } - @Test @MainActor func menuContextCardInjectorInsertsAndFindsIndex() { + @Test @MainActor func `menu context card injector inserts and finds index`() { let injector = MenuContextCardInjector() let menu = NSMenu() menu.minimumWidth = 280 @@ -190,7 +190,7 @@ struct LowCoverageHelperTests { #expect(injector._testFindInsertIndex(in: fallbackMenu) == 1) } - @Test @MainActor func canvasWindowHelperFunctions() throws { + @Test @MainActor func `canvas window helper functions`() throws { #expect(CanvasWindowController._testSanitizeSessionKey(" main ") == "main") #expect(CanvasWindowController._testSanitizeSessionKey("bad/..") == "bad___") #expect(CanvasWindowController._testJSOptionalStringLiteral(nil) == "null") diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift index 0a9b12ed31324..0526e287b0225 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift @@ -7,7 +7,7 @@ import Testing @Suite(.serialized) @MainActor struct LowCoverageViewSmokeTests { - @Test func contextMenuCardBuildsBody() { + @Test func `context menu card builds body`() { let loading = ContextMenuCardView(rows: [], statusText: "Loading…", isLoading: true) _ = loading.body @@ -18,14 +18,14 @@ struct LowCoverageViewSmokeTests { _ = withRows.body } - @Test func settingsToggleRowBuildsBody() { + @Test func `settings toggle row builds body`() { var flag = false let binding = Binding(get: { flag }, set: { flag = $0 }) let view = SettingsToggleRow(title: "Enable", subtitle: "Detail", binding: binding) _ = view.body } - @Test func voiceWakeTestCardBuildsBodyAcrossStates() { + @Test func `voice wake test card builds body across states`() { var state = VoiceWakeTestState.idle var isTesting = false let stateBinding = Binding(get: { state }, set: { state = $0 }) @@ -44,7 +44,7 @@ struct LowCoverageViewSmokeTests { _ = VoiceWakeTestCard(testState: stateBinding, isTesting: testingBinding, onToggle: {}).body } - @Test func agentEventsWindowBuildsBodyWithEvent() { + @Test func `agent events window builds body with event`() { AgentEventStore.shared.clear() let sample = ControlAgentEvent( runId: "run-1", @@ -58,7 +58,7 @@ struct LowCoverageViewSmokeTests { AgentEventStore.shared.clear() } - @Test func notifyOverlayPresentsAndDismisses() async { + @Test func `notify overlay presents and dismisses`() async { let controller = NotifyOverlayController() controller.present(title: "Hello", body: "World", autoDismissAfter: 0) controller.present(title: "Updated", body: "Again", autoDismissAfter: 0) @@ -66,14 +66,14 @@ struct LowCoverageViewSmokeTests { try? await Task.sleep(nanoseconds: 250_000_000) } - @Test func visualEffectViewHostsInNSHostingView() { + @Test func `visual effect view hosts in NS hosting view`() { let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar)) _ = hosting.fittingSize hosting.rootView = VisualEffectView(material: .popover, emphasized: true) _ = hosting.fittingSize } - @Test func menuHostedItemHostsContent() { + @Test func `menu hosted item hosts content`() { let view = MenuHostedItem(width: 240, rootView: AnyView(Text("Menu"))) let hosting = NSHostingView(rootView: view) _ = hosting.fittingSize @@ -81,18 +81,18 @@ struct LowCoverageViewSmokeTests { _ = hosting.fittingSize } - @Test func dockIconManagerUpdatesVisibility() { + @Test func `dock icon manager updates visibility`() { _ = NSApplication.shared UserDefaults.standard.set(false, forKey: showDockIconKey) DockIconManager.shared.updateDockVisibility() DockIconManager.shared.temporarilyShowDock() } - @Test func voiceWakeSettingsExercisesHelpers() { + @Test func `voice wake settings exercises helpers`() { VoiceWakeSettings.exerciseForTesting() } - @Test func debugSettingsExercisesHelpers() async { + @Test func `debug settings exercises helpers`() async { await DebugSettings.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 2d26b7c053828..5adfc037dd745 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -3,8 +3,8 @@ import OpenClawProtocol import Testing @testable import OpenClaw -@Suite struct MacGatewayChatTransportMappingTests { - @Test func snapshotMapsToHealth() { +struct MacGatewayChatTransportMappingTests { + @Test func `snapshot maps to health`() { let snapshot = Snapshot( presence: [], health: OpenClawProtocol.AnyCodable(["ok": OpenClawProtocol.AnyCodable(false)]), @@ -35,7 +35,7 @@ import Testing } } - @Test func healthEventMapsToHealth() { + @Test func `health event maps to health`() { let frame = EventFrame( type: "event", event: "health", @@ -52,7 +52,7 @@ import Testing } } - @Test func tickEventMapsToTick() { + @Test func `tick event maps to tick`() { let frame = EventFrame(type: "event", event: "tick", payload: nil, seq: 1, stateversion: nil) let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.event(frame)) #expect({ @@ -61,7 +61,7 @@ import Testing }()) } - @Test func chatEventMapsToChat() { + @Test func `chat event maps to chat`() { let payload = OpenClawProtocol.AnyCodable([ "runId": OpenClawProtocol.AnyCodable("run-1"), "sessionKey": OpenClawProtocol.AnyCodable("main"), @@ -80,7 +80,7 @@ import Testing } } - @Test func unknownEventMapsToNil() { + @Test func `unknown event maps to nil`() { let frame = EventFrame( type: "event", event: "unknown", @@ -91,7 +91,7 @@ import Testing #expect(mapped == nil) } - @Test func seqGapMapsToSeqGap() { + @Test func `seq gap maps to seq gap`() { let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.seqGap(expected: 1, received: 9)) #expect({ if case .seqGap = mapped { return true } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift index 581f41dedaf29..c000f6d424162 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift @@ -3,7 +3,7 @@ import Testing @testable import OpenClaw struct MacNodeBrowserProxyTests { - @Test func requestUsesBrowserControlEndpointAndWrapsResult() async throws { + @Test func `request uses browser control endpoint and wraps result`() async throws { let proxy = MacNodeBrowserProxy( endpointProvider: { MacNodeBrowserProxy.Endpoint( diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift index a156559317eed..20b4184f5c989 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift @@ -5,14 +5,14 @@ import Testing @testable import OpenClaw struct MacNodeRuntimeTests { - @Test func handleInvokeRejectsUnknownCommand() async { + @Test func `handle invoke rejects unknown command`() async { let runtime = MacNodeRuntime() let response = await runtime.handleInvoke( BridgeInvokeRequest(id: "req-1", command: "unknown.command")) #expect(response.ok == false) } - @Test func handleInvokeRejectsEmptySystemRun() async throws { + @Test func `handle invoke rejects empty system run`() async throws { let runtime = MacNodeRuntime() let params = OpenClawSystemRunParams(command: []) let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) @@ -21,7 +21,7 @@ struct MacNodeRuntimeTests { #expect(response.ok == false) } - @Test func handleInvokeRejectsEmptySystemWhich() async throws { + @Test func `handle invoke rejects empty system which`() async throws { let runtime = MacNodeRuntime() let params = OpenClawSystemWhichParams(bins: []) let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) @@ -30,7 +30,7 @@ struct MacNodeRuntimeTests { #expect(response.ok == false) } - @Test func handleInvokeRejectsEmptyNotification() async throws { + @Test func `handle invoke rejects empty notification`() async throws { let runtime = MacNodeRuntime() let params = OpenClawSystemNotifyParams(title: "", body: "") let json = try String(data: JSONEncoder().encode(params), encoding: .utf8) @@ -39,7 +39,7 @@ struct MacNodeRuntimeTests { #expect(response.ok == false) } - @Test func handleInvokeCameraListRequiresEnabledCamera() async { + @Test func `handle invoke camera list requires enabled camera`() async { await TestIsolation.withUserDefaultsValues([cameraEnabledKey: false]) { let runtime = MacNodeRuntime() let response = await runtime.handleInvoke( @@ -49,7 +49,7 @@ struct MacNodeRuntimeTests { } } - @Test func handleInvokeScreenRecordUsesInjectedServices() async throws { + @Test func `handle invoke screen record uses injected services`() async throws { @MainActor final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable { func recordScreen( @@ -101,20 +101,23 @@ struct MacNodeRuntimeTests { #expect(!payload.base64.isEmpty) } - @Test func handleInvokeBrowserProxyUsesInjectedRequest() async throws { + @Test func `handle invoke browser proxy uses injected request`() async { let runtime = MacNodeRuntime(browserProxyRequest: { paramsJSON in #expect(paramsJSON?.contains("/tabs") == true) return #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"# }) let paramsJSON = #"{"method":"GET","path":"/tabs","timeoutMs":2500}"# let response = await runtime.handleInvoke( - BridgeInvokeRequest(id: "req-browser", command: OpenClawBrowserCommand.proxy.rawValue, paramsJSON: paramsJSON)) + BridgeInvokeRequest( + id: "req-browser", + command: OpenClawBrowserCommand.proxy.rawValue, + paramsJSON: paramsJSON)) #expect(response.ok == true) #expect(response.payloadJSON == #"{"result":{"ok":true,"tabs":[{"id":"tab-1"}]}}"#) } - @Test func handleInvokeBrowserProxyRejectsDisabledBrowserControl() async throws { + @Test func `handle invoke browser proxy rejects disabled browser control`() async throws { let override = TestIsolation.tempConfigPath() try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { try JSONSerialization.data(withJSONObject: ["browser": ["enabled": false]]) diff --git a/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift index c6d58cc3a86b3..bf39f4ebfea1e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MasterDiscoveryMenuSmokeTests.swift @@ -6,7 +6,7 @@ import Testing @Suite(.serialized) @MainActor struct MasterDiscoveryMenuSmokeTests { - @Test func inlineListBuildsBodyWhenEmpty() { + @Test func `inline list builds body when empty`() { let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Searching…" discovery.gateways = [] @@ -20,7 +20,7 @@ struct MasterDiscoveryMenuSmokeTests { _ = view.body } - @Test func inlineListBuildsBodyWithMasterAndSelection() { + @Test func `inline list builds body with master and selection`() { let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Found 1" discovery.gateways = [ @@ -46,7 +46,7 @@ struct MasterDiscoveryMenuSmokeTests { _ = view.body } - @Test func menuBuildsBodyWithMasters() { + @Test func `menu builds body with masters`() { let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName) discovery.statusText = "Found 2" discovery.gateways = [ diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift index a57782148e47c..cab820fe0e31e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift @@ -5,28 +5,28 @@ import Testing @Suite(.serialized) @MainActor struct MenuContentSmokeTests { - @Test func menuContentBuildsBodyLocalMode() { + @Test func `menu content builds body local mode`() { let state = AppState(preview: true) state.connectionMode = .local let view = MenuContent(state: state, updater: nil) _ = view.body } - @Test func menuContentBuildsBodyRemoteMode() { + @Test func `menu content builds body remote mode`() { let state = AppState(preview: true) state.connectionMode = .remote let view = MenuContent(state: state, updater: nil) _ = view.body } - @Test func menuContentBuildsBodyUnconfiguredMode() { + @Test func `menu content builds body unconfigured mode`() { let state = AppState(preview: true) state.connectionMode = .unconfigured let view = MenuContent(state: state, updater: nil) _ = view.body } - @Test func menuContentBuildsBodyWithDebugAndCanvas() { + @Test func `menu content builds body with debug and canvas`() { let state = AppState(preview: true) state.connectionMode = .local state.debugPaneEnabled = true diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index ff63673b9e08b..186675f1eeacf 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct MenuSessionsInjectorTests { - @Test func injectsDisconnectedMessage() { + @Test func `injects disconnected message`() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(false) injector.setTestingSnapshot(nil, errorText: nil) @@ -19,7 +19,7 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) } - @Test func injectsSessionRows() { + @Test func `injects session rows`() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) @@ -94,7 +94,7 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem }) } - @Test func costUsageSubmenuDoesNotUseInjectorDelegate() { + @Test func `cost usage submenu does not use injector delegate`() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) diff --git a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift index 05ed6f8513bbc..f3ddc6287c89a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct ModelCatalogLoaderTests { @Test - func loadParsesModelsFromTypeScriptAndSorts() async throws { + func `load parses models from type script and sorts`() async throws { let src = """ export const MODELS = { openai: { @@ -40,7 +39,7 @@ struct ModelCatalogLoaderTests { } @Test - func loadWithNoExportReturnsEmptyChoices() async throws { + func `load with no export returns empty choices`() async throws { let src = "const NOPE = 1;" let tmp = FileManager().temporaryDirectory .appendingPathComponent("models-\(UUID().uuidString).ts") diff --git a/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift b/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift index e95d2097072b8..ad3a67ebd1c7b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) struct NixModeStableSuiteTests { - @Test func resolvesFromStableSuiteForAppBundles() throws { + @Test func `resolves from stable suite for app bundles`() throws { let suite = try #require(UserDefaults(suiteName: launchdLabel)) let key = "openclaw.nixMode" let prev = suite.object(forKey: key) @@ -25,7 +25,7 @@ struct NixModeStableSuiteTests { #expect(resolved) } - @Test func ignoresStableSuiteOutsideAppBundles() throws { + @Test func `ignores stable suite outside app bundles`() throws { let suite = try #require(UserDefaults(suiteName: launchdLabel)) let key = "openclaw.nixMode" let prev = suite.object(forKey: key) diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift index 7f2a53d43b7c7..e9e36d5f2b068 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct NodeManagerPathsTests { - @Test func fnmNodeBinsPreferNewestInstalledVersion() throws { +struct NodeManagerPathsTests { + @Test func `fnm node bins prefer newest installed version`() throws { let home = try makeTempDirForTests() let v20Bin = home @@ -18,7 +18,7 @@ import Testing #expect(bins.contains(v20Bin.deletingLastPathComponent().path)) } - @Test func ignoresEntriesWithoutNodeExecutable() throws { + @Test func `ignores entries without node executable`() throws { let home = try makeTempDirForTests() let missingNodeBin = home .appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin") diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift index 7c2a90e456ec7..718447146119b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) @MainActor struct NodePairingApprovalPrompterTests { - @Test func nodePairingApprovalPrompterExercises() async { + @Test func `node pairing approval prompter exercises`() async { await NodePairingApprovalPrompter.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift index cc1113f789cde..a7d1c30642ea6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift @@ -1,14 +1,14 @@ import Testing @testable import OpenClaw -@Suite struct NodePairingReconcilePolicyTests { - @Test func policyPollsOnlyWhenActive() { +struct NodePairingReconcilePolicyTests { + @Test func `policy polls only when active`() { #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false) #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false)) #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true)) } - @Test func policyUsesSlowSafetyInterval() { + @Test func `policy uses slow safety interval`() { #expect(NodePairingReconcilePolicy.activeIntervalMs >= 10000) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift index e79d002683c83..0ee42db266919 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingCoverageTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) @MainActor struct OnboardingCoverageTests { - @Test func exerciseOnboardingPages() { + @Test func `exercise onboarding pages`() { OnboardingView.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index b824b2b083593..5b816d3cd5a88 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -7,7 +7,7 @@ import Testing @Suite(.serialized) @MainActor struct OnboardingViewSmokeTests { - @Test func onboardingViewBuildsBody() { + @Test func `onboarding view builds body`() { let state = AppState(preview: true) let view = OnboardingView( state: state, @@ -16,18 +16,18 @@ struct OnboardingViewSmokeTests { _ = view.body } - @Test func pageOrderOmitsWorkspaceAndIdentitySteps() { + @Test func `page order omits workspace and identity steps`() { let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) #expect(!order.contains(7)) #expect(order.contains(3)) } - @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { + @Test func `page order omits onboarding chat when identity known`() { let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) #expect(!order.contains(8)) } - @Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async { + @Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async { let override = FileManager().temporaryDirectory .appendingPathComponent("openclaw-config-\(UUID().uuidString)") .appendingPathComponent("openclaw.json") diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift index 7211482fea227..e05fd5ba95096 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift @@ -8,7 +8,7 @@ private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable @Suite(.serialized) @MainActor struct OnboardingWizardStepViewTests { - @Test func noteStepBuilds() { + @Test func `note step builds`() { let step = WizardStep( id: "step-1", type: ProtoAnyCodable("note"), @@ -23,7 +23,7 @@ struct OnboardingWizardStepViewTests { _ = view.body } - @Test func selectStepBuilds() { + @Test func `select step builds`() { let options: [[String: ProtoAnyCodable]] = [ ["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], ["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 7c3804eb494b6..fcc8ddca1b3cf 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -12,8 +12,8 @@ struct OpenClawConfigFileTests { } @Test - func configPathRespectsEnvOverride() async { - let override = makeConfigOverridePath() + func `config path respects env override`() async { + let override = self.makeConfigOverridePath() await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { #expect(OpenClawConfigFile.url().path == override) @@ -22,8 +22,8 @@ struct OpenClawConfigFileTests { @MainActor @Test - func remoteGatewayPortParsesAndMatchesHost() async { - let override = makeConfigOverridePath() + func `remote gateway port parses and matches host`() async { + let override = self.makeConfigOverridePath() await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { OpenClawConfigFile.saveDict([ @@ -42,8 +42,8 @@ struct OpenClawConfigFileTests { @MainActor @Test - func setRemoteGatewayUrlPreservesScheme() async { - let override = makeConfigOverridePath() + func `set remote gateway url preserves scheme`() async { + let override = self.makeConfigOverridePath() await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { OpenClawConfigFile.saveDict([ @@ -62,8 +62,8 @@ struct OpenClawConfigFileTests { @MainActor @Test - func clearRemoteGatewayUrlRemovesOnlyUrlField() async { - let override = makeConfigOverridePath() + func `clear remote gateway url removes only url field`() async { + let override = self.makeConfigOverridePath() await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { OpenClawConfigFile.saveDict([ @@ -83,7 +83,7 @@ struct OpenClawConfigFileTests { } @Test - func stateDirOverrideSetsConfigPath() async { + func `state dir override sets config path`() async { let dir = FileManager().temporaryDirectory .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) .path @@ -99,7 +99,7 @@ struct OpenClawConfigFileTests { @MainActor @Test - func saveDictAppendsConfigAuditLog() async throws { + func `save dict appends config audit log`() async throws { let stateDir = FileManager().temporaryDirectory .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) let configPath = stateDir.appendingPathComponent("openclaw.json") diff --git a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift index ca3fd2b9dacd1..2edf040bb75e4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift @@ -2,16 +2,15 @@ import CoreLocation import Testing @testable import OpenClaw -@Suite("PermissionManager Location") struct PermissionManagerLocationTests { - @Test("authorizedAlways counts for both modes") - func authorizedAlwaysCountsForBothModes() { + @Test + func `authorizedAlways counts for both modes`() { #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false)) #expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true)) } - @Test("other statuses not authorized") - func otherStatusesNotAuthorized() { + @Test + func `other statuses not authorized`() { #expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false)) #expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false)) #expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false)) diff --git a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift index 4ff347122e516..900105c954f59 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerTests.swift @@ -6,31 +6,31 @@ import Testing @Suite(.serialized) @MainActor struct PermissionManagerTests { - @Test func voiceWakePermissionHelpersMatchStatus() async { + @Test func `voice wake permission helpers match status`() async { let direct = PermissionManager.voiceWakePermissionsGranted() let ensured = await PermissionManager.ensureVoiceWakePermissions(interactive: false) #expect(ensured == direct) } - @Test func statusCanQueryNonInteractiveCaps() async { + @Test func `status can query non interactive caps`() async { let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] let status = await PermissionManager.status(caps) #expect(status.keys.count == caps.count) } - @Test func ensureNonInteractiveDoesNotThrow() async { + @Test func `ensure non interactive does not throw`() async { let caps: [Capability] = [.microphone, .speechRecognition, .screenRecording] let ensured = await PermissionManager.ensure(caps, interactive: false) #expect(ensured.keys.count == caps.count) } - @Test func locationStatusMatchesAuthorizationAlways() async { + @Test func `location status matches authorization always`() async { let status = CLLocationManager().authorizationStatus let results = await PermissionManager.status([.location]) #expect(results[.location] == (status == .authorizedAlways)) } - @Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() async { + @Test func `ensure location non interactive matches authorization always`() async { let status = CLLocationManager().authorizationStatus let ensured = await PermissionManager.ensure([.location], interactive: false) #expect(ensured[.location] == (status == .authorizedAlways)) diff --git a/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift b/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift index 14e5c056b0971..10e60ac537668 100644 --- a/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift +++ b/apps/macos/Tests/OpenClawIPCTests/Placeholder.swift @@ -1,6 +1,6 @@ import Testing -@Suite struct PlaceholderTests { +struct PlaceholderTests { @Test func placeholder() { #expect(true) } diff --git a/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift b/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift index 856af89676c01..34298b1a7136a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift @@ -5,8 +5,8 @@ import Testing import Darwin import Foundation -@Suite struct RemotePortTunnelTests { - @Test func drainStderrDoesNotCrashWhenHandleClosed() { +struct RemotePortTunnelTests { + @Test func `drain stderr does not crash when handle closed`() { let pipe = Pipe() let handle = pipe.fileHandleForReading try? handle.close() @@ -15,7 +15,7 @@ import Foundation #expect(drained.isEmpty) } - @Test func portIsFreeDetectsIPv4Listener() { + @Test func `port is free detects I pv4 listener`() { var fd = socket(AF_INET, SOCK_STREAM, 0) #expect(fd >= 0) guard fd >= 0 else { return } diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 6662132c9ac72..990c033445fe7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct RuntimeLocatorTests { +struct RuntimeLocatorTests { private func makeTempExecutable(contents: String) throws -> URL { let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -13,7 +13,7 @@ import Testing return path } - @Test func resolveSucceedsWithValidNode() throws { + @Test func `resolve succeeds with valid node`() throws { let script = """ #!/bin/sh echo v22.5.0 @@ -28,7 +28,7 @@ import Testing #expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0)) } - @Test func resolveFailsWhenTooOld() throws { + @Test func `resolve fails when too old`() throws { let script = """ #!/bin/sh echo v18.2.0 @@ -43,7 +43,7 @@ import Testing #expect(path == node.path) } - @Test func resolveFailsWhenVersionUnparsable() throws { + @Test func `resolve fails when version unparsable`() throws { let script = """ #!/bin/sh echo node-version:unknown @@ -58,12 +58,12 @@ import Testing #expect(path == node.path) } - @Test func describeFailureIncludesPaths() { + @Test func `describe failure includes paths`() { let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) } - @Test func runtimeVersionParsesWithLeadingVAndMetadata() { + @Test func `runtime version parses with leading V and metadata`() { #expect(RuntimeVersion.from(string: "v22.1.3") == RuntimeVersion(major: 22, minor: 1, patch: 3)) #expect(RuntimeVersion.from(string: "node 22.3.0-alpha.1") == RuntimeVersion(major: 22, minor: 3, patch: 0)) #expect(RuntimeVersion.from(string: "bogus") == nil) diff --git a/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift index 84fe17751dd5f..7f72d6e18b1db 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ScreenshotSizeTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct ScreenshotSizeTests { @Test - func readPNGSizeReturnsDimensions() throws { + func `read PNG size returns dimensions`() throws { let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+WZxkAAAAASUVORK5CYII=" let data = try #require(Data(base64Encoded: pngBase64)) @@ -15,7 +14,7 @@ struct ScreenshotSizeTests { } @Test - func readPNGSizeRejectsNonPNGData() { + func `read PNG size rejects non PNG data`() { #expect(ScreenshotSize.readPNGSize(data: Data("nope".utf8)) == nil) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift b/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift index 83d8e8478f906..19b9f4496025b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SemverTests.swift @@ -1,8 +1,8 @@ import Testing @testable import OpenClaw -@Suite struct SemverTests { - @Test func comparisonOrdersByMajorMinorPatch() { +struct SemverTests { + @Test func `comparison orders by major minor patch`() { let a = Semver(major: 1, minor: 0, patch: 0) let b = Semver(major: 1, minor: 1, patch: 0) let c = Semver(major: 1, minor: 1, patch: 1) @@ -14,7 +14,7 @@ import Testing #expect(d > a) } - @Test func descriptionMatchesParts() { + @Test func `description matches parts`() { let v = Semver(major: 3, minor: 2, patch: 1) #expect(v.description == "3.2.1") } diff --git a/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift b/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift index f1594ba7b543f..c8e3a812b09a5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SessionDataTests.swift @@ -2,27 +2,26 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct SessionDataTests { - @Test func sessionKindFromKeyDetectsCommonKinds() { + @Test func `session kind from key detects common kinds`() { #expect(SessionKind.from(key: "global") == .global) #expect(SessionKind.from(key: "discord:group:engineering") == .group) #expect(SessionKind.from(key: "unknown") == .unknown) #expect(SessionKind.from(key: "user@example.com") == .direct) } - @Test func sessionTokenStatsFormatKTokensRoundsAsExpected() { + @Test func `session token stats format K tokens rounds as expected`() { #expect(SessionTokenStats.formatKTokens(999) == "999") #expect(SessionTokenStats.formatKTokens(1000) == "1.0k") #expect(SessionTokenStats.formatKTokens(12340) == "12k") } - @Test func sessionTokenStatsPercentUsedClampsTo100() { + @Test func `session token stats percent used clamps to100`() { let stats = SessionTokenStats(input: 0, output: 0, total: 250_000, contextTokens: 200_000) #expect(stats.percentUsed == 100) } - @Test func sessionRowFlagLabelsIncludeNonDefaultFlags() { + @Test func `session row flag labels include non default flags`() { let row = SessionRow( id: "x", key: "user@example.com", diff --git a/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift index 44bb3c39c2cf4..39ed83f750c3b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SessionMenuPreviewTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) struct SessionMenuPreviewTests { - @Test func loaderReturnsCachedItems() async { + @Test func `loader returns cached items`() async { await SessionPreviewCache.shared._testReset() let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")] let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready) @@ -16,7 +16,7 @@ struct SessionMenuPreviewTests { #expect(loaded.items.first?.text == "Hi") } - @Test func loaderReturnsEmptyWhenCachedEmpty() async { + @Test func `loader returns empty when cached empty`() async { await SessionPreviewCache.shared._testReset() let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty) await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main") diff --git a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift index f9de602e25905..f26367b991ad1 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct SettingsViewSmokeTests { - @Test func cronSettingsBuildsBody() { + @Test func `cron settings builds body`() { let store = CronJobsStore(isPreview: true) store.schedulerEnabled = false store.schedulerStorePath = "/tmp/openclaw-cron-store.json" @@ -80,36 +80,36 @@ struct SettingsViewSmokeTests { _ = view.body } - @Test func cronSettingsExercisesPrivateViews() { + @Test func `cron settings exercises private views`() { CronSettings.exerciseForTesting() } - @Test func configSettingsBuildsBody() { + @Test func `config settings builds body`() { let view = ConfigSettings() _ = view.body } - @Test func debugSettingsBuildsBody() { + @Test func `debug settings builds body`() { let view = DebugSettings() _ = view.body } - @Test func generalSettingsBuildsBody() { + @Test func `general settings builds body`() { let state = AppState(preview: true) let view = GeneralSettings(state: state) _ = view.body } - @Test func generalSettingsExercisesBranches() { + @Test func `general settings exercises branches`() { GeneralSettings.exerciseForTesting() } - @Test func sessionsSettingsBuildsBody() { + @Test func `sessions settings builds body`() { let view = SessionsSettings(rows: SessionRow.previewRows, isPreview: true) _ = view.body } - @Test func instancesSettingsBuildsBody() { + @Test func `instances settings builds body`() { let store = InstancesStore(isPreview: true) store.instances = [ InstanceInfo( @@ -130,7 +130,7 @@ struct SettingsViewSmokeTests { _ = view.body } - @Test func permissionsSettingsBuildsBody() { + @Test func `permissions settings builds body`() { let view = PermissionsSettings( status: [ .notifications: true, @@ -141,24 +141,24 @@ struct SettingsViewSmokeTests { _ = view.body } - @Test func settingsRootViewBuildsBody() { + @Test func `settings root view builds body`() { let state = AppState(preview: true) let view = SettingsRootView(state: state, updater: nil, initialTab: .general) _ = view.body } - @Test func aboutSettingsBuildsBody() { + @Test func `about settings builds body`() { let view = AboutSettings(updater: nil) _ = view.body } - @Test func voiceWakeSettingsBuildsBody() { + @Test func `voice wake settings builds body`() { let state = AppState(preview: true) let view = VoiceWakeSettings(state: state, isActive: false) _ = view.body } - @Test func skillsSettingsBuildsBody() { + @Test func `skills settings builds body`() { let view = SkillsSettings(state: .preview) _ = view.body } diff --git a/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift index ad2ae573ca29a..d3353f68de91e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift @@ -41,7 +41,7 @@ private func makeSkillStatus( @Suite(.serialized) @MainActor struct SkillsSettingsSmokeTests { - @Test func skillsSettingsBuildsBodyWithSkillsRemote() { + @Test func `skills settings builds body with skills remote`() { let model = SkillsSettingsModel() model.statusMessage = "Loaded" model.skills = [ @@ -103,7 +103,7 @@ struct SkillsSettingsSmokeTests { _ = view.body } - @Test func skillsSettingsBuildsBodyWithLocalMode() { + @Test func `skills settings builds body with local mode`() { let model = SkillsSettingsModel() model.skills = [ makeSkillStatus( @@ -123,7 +123,7 @@ struct SkillsSettingsSmokeTests { _ = view.body } - @Test func skillsSettingsExercisesPrivateViews() { + @Test func `skills settings exercises private views`() { SkillsSettings.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift index fdfa96cbebbca..13cd622b92055 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct TailscaleIntegrationSectionTests { - @Test func tailscaleSectionBuildsBodyWhenNotInstalled() { + @Test func `tailscale section builds body when not installed`() { let service = TailscaleService(isInstalled: false, isRunning: false, statusError: "not installed") var view = TailscaleIntegrationSection(connectionMode: .local, isPaused: false) view.setTestingService(service) @@ -13,7 +13,7 @@ struct TailscaleIntegrationSectionTests { _ = view.body } - @Test func tailscaleSectionBuildsBodyForServeMode() { + @Test func `tailscale section builds body for serve mode`() { let service = TailscaleService( isInstalled: true, isRunning: true, @@ -29,7 +29,7 @@ struct TailscaleIntegrationSectionTests { _ = view.body } - @Test func tailscaleSectionBuildsBodyForFunnelMode() { + @Test func `tailscale section builds body for funnel mode`() { let service = TailscaleService( isInstalled: true, isRunning: false, diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift index 78c660622b023..46feb9a795eba 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift @@ -2,9 +2,8 @@ import Foundation import Testing @testable import OpenClawDiscovery -@Suite struct TailscaleServeGatewayDiscoveryTests { - @Test func discoversServeGatewayFromTailnetPeers() async { + @Test func `discovers serve gateway from tailnet peers`() async { let statusJson = """ { "Self": { @@ -46,7 +45,7 @@ struct TailscaleServeGatewayDiscoveryTests { #expect(beacons.first?.port == 443) } - @Test func returnsEmptyWhenStatusUnavailable() async { + @Test func `returns empty when status unavailable`() async { let context = TailscaleServeGatewayDiscovery.DiscoveryContext( tailscaleStatus: { nil }, probeHost: { _, _ in true }) @@ -55,7 +54,7 @@ struct TailscaleServeGatewayDiscoveryTests { #expect(beacons.isEmpty) } - @Test func resolvesBareExecutableFromPATH() throws { + @Test func `resolves bare executable from PATH`() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -70,8 +69,9 @@ struct TailscaleServeGatewayDiscoveryTests { #expect(resolved == executable.path) } - @Test func rejectsMissingExecutableCandidate() { + @Test func `rejects missing executable candidate`() { #expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("", env: [:]) == nil) - #expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil) + #expect(TailscaleServeGatewayDiscovery + .resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift index bba233fa0c4b4..d2b5b00792304 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) struct TalkAudioPlayerTests { @MainActor - @Test func playDoesNotHangWhenPlaybackEndsOrFails() async throws { + @Test func `play does not hang when playback ends or fails`() async throws { let wav = makeWav16Mono(sampleRate: 8000, samples: 80) defer { _ = TalkAudioPlayer.shared.stop() } @@ -16,7 +16,7 @@ import Testing } @MainActor - @Test func playDoesNotHangWhenPlayIsCalledTwice() async throws { + @Test func `play does not hang when play is called twice`() async throws { let wav = makeWav16Mono(sampleRate: 8000, samples: 800) defer { _ = TalkAudioPlayer.shared.stop() } diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift index f7f93c4e81e13..baf7302d3634d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -2,8 +2,8 @@ import OpenClawProtocol import Testing @testable import OpenClaw -@Suite struct TalkModeConfigParsingTests { - @Test func prefersNormalizedTalkProviderPayload() { +struct TalkModeConfigParsingTests { + @Test func `prefers normalized talk provider payload`() { let talk: [String: AnyCodable] = [ "provider": AnyCodable("elevenlabs"), "providers": AnyCodable([ @@ -20,7 +20,7 @@ import Testing #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") } - @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { + @Test func `falls back to legacy talk fields when normalized payload missing`() { let talk: [String: AnyCodable] = [ "voiceId": AnyCodable("voice-legacy"), "apiKey": AnyCodable("legacy-key"), diff --git a/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift b/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift index 049ed503b6169..7307dc68786d5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/UtilitiesTests.swift @@ -3,7 +3,7 @@ import Testing @testable import OpenClaw @Suite(.serialized) struct UtilitiesTests { - @Test func ageStringsCoverCommonWindows() { + @Test func `age strings cover common windows`() { let now = Date(timeIntervalSince1970: 1_000_000) #expect(age(from: now, now: now) == "just now") #expect(age(from: now.addingTimeInterval(-45), now: now) == "just now") @@ -15,7 +15,7 @@ import Testing #expect(age(from: now.addingTimeInterval(-3 * 86400), now: now) == "3d ago") } - @Test func parseSSHTargetSupportsUserPortAndDefaults() { + @Test func `parse SSH target supports user port and defaults`() { let parsed1 = CommandResolver.parseSSHTarget("alice@example.com:2222") #expect(parsed1?.user == "alice") #expect(parsed1?.host == "example.com") @@ -32,7 +32,7 @@ import Testing #expect(parsed3?.port == 22) } - @Test func sanitizedTargetStripsLeadingSSHPrefix() throws { + @Test func `sanitized target strips leading SSH prefix`() throws { let defaults = try #require(UserDefaults(suiteName: "UtilitiesTests.\(UUID().uuidString)")) defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) defaults.set("ssh alice@example.com", forKey: remoteTargetKey) @@ -42,7 +42,7 @@ import Testing #expect(settings.target == "alice@example.com") } - @Test func gatewayEntrypointPrefersDistOverBin() throws { + @Test func `gateway entrypoint prefers dist over bin`() throws { let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) let dist = tmp.appendingPathComponent("dist/index.js") @@ -56,7 +56,7 @@ import Testing #expect(entry == dist.path) } - @Test func logLocatorPicksNewestLogFile() throws { + @Test func `log locator picks newest log file`() throws { let fm = FileManager() let dir = URL(fileURLWithPath: "/tmp/openclaw", isDirectory: true) try? fm.createDirectory(at: dir, withIntermediateDirectories: true) @@ -75,7 +75,7 @@ import Testing try? fm.removeItem(at: newer) } - @Test func gatewayEntrypointNilWhenMissing() { + @Test func `gateway entrypoint nil when missing`() { let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) #expect(CommandResolver.gatewayEntrypoint(in: tmp) == nil) diff --git a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift index 9c1006fbb0bd2..921a41415cb9f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkHotkeyTests.swift @@ -20,7 +20,7 @@ import Testing } } - @Test func beginEndFiresOncePerHold() async { + @Test func `begin end fires once per hold`() async { let counter = Counter() let hotkey = VoicePushToTalkHotkey( beginAction: { await counter.incBegin() }, diff --git a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift index 4a69bfea941ae..aeb1d700474a2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoicePushToTalkTests.swift @@ -1,23 +1,23 @@ import Testing @testable import OpenClaw -@Suite struct VoicePushToTalkTests { - @Test func deltaTrimsCommittedPrefix() { +struct VoicePushToTalkTests { + @Test func `delta trims committed prefix`() { let delta = VoicePushToTalk._testDelta(committed: "hello ", current: "hello world again") #expect(delta == "world again") } - @Test func deltaFallsBackWhenPrefixDiffers() { + @Test func `delta falls back when prefix differs`() { let delta = VoicePushToTalk._testDelta(committed: "goodbye", current: "hello world") #expect(delta == "hello world") } - @Test func attributedColorsDifferWhenNotFinal() { + @Test func `attributed colors differ when not final`() { let colors = VoicePushToTalk._testAttributedColors(isFinal: false) #expect(colors.0 != colors.1) } - @Test func attributedColorsMatchWhenFinal() { + @Test func `attributed colors match when final`() { let colors = VoicePushToTalk._testAttributedColors(isFinal: true) #expect(colors.0 == colors.1) } diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift index 6640d526a7411..debfc6cccc4c4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeForwarderTests.swift @@ -2,7 +2,7 @@ import Testing @testable import OpenClaw @Suite(.serialized) struct VoiceWakeForwarderTests { - @Test func prefixedTranscriptUsesMachineName() { + @Test func `prefixed transcript uses machine name`() { let transcript = "hello world" let prefixed = VoiceWakeForwarder.prefixedTranscript(transcript, machineName: "My-Mac") @@ -11,7 +11,7 @@ import Testing #expect(prefixed.hasSuffix("\n\nhello world")) } - @Test func forwardOptionsDefaults() { + @Test func `forward options defaults`() { let opts = VoiceWakeForwarder.ForwardOptions() #expect(opts.sessionKey == "main") #expect(opts.thinking == "low") diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift index d19a9ccc25f9a..4ababab0bf0d9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift @@ -21,9 +21,12 @@ import Testing return previous } - @Test func appliesVoiceWakeChangedEventToAppState() async { + @Test func `applies voice wake changed event to app state`() async { let previous = await applyTriggersAndCapturePrevious(["before"]) - let evt = voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable(["triggers": ["openclaw", "computer"]])) + let evt = self.voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable(["triggers": [ + "openclaw", + "computer", + ]])) await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) @@ -35,9 +38,9 @@ import Testing } } - @Test func ignoresVoiceWakeChangedEventWithInvalidPayload() async { + @Test func `ignores voice wake changed event with invalid payload`() async { let previous = await applyTriggersAndCapturePrevious(["before"]) - let evt = voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable(["unexpected": 123])) + let evt = self.voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable(["unexpected": 123])) await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt)) diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift index 20ba7d7c4f502..24bb376bf92e4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeHelpersTests.swift @@ -2,33 +2,33 @@ import Testing @testable import OpenClaw struct VoiceWakeHelpersTests { - @Test func sanitizeTriggersTrimsAndDropsEmpty() { + @Test func `sanitize triggers trims and drops empty`() { let cleaned = sanitizeVoiceWakeTriggers([" hi ", " ", "\n", "there"]) #expect(cleaned == ["hi", "there"]) } - @Test func sanitizeTriggersFallsBackToDefaults() { + @Test func `sanitize triggers falls back to defaults`() { let cleaned = sanitizeVoiceWakeTriggers([" ", ""]) #expect(cleaned == defaultVoiceWakeTriggers) } - @Test func sanitizeTriggersLimitsWordLength() { + @Test func `sanitize triggers limits word length`() { let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5) let cleaned = sanitizeVoiceWakeTriggers(["ok", long]) #expect(cleaned[1].count == voiceWakeMaxWordLength) } - @Test func sanitizeTriggersLimitsWordCount() { + @Test func `sanitize triggers limits word count`() { let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" } let cleaned = sanitizeVoiceWakeTriggers(words) #expect(cleaned.count == voiceWakeMaxWords) } - @Test func normalizeLocaleStripsCollation() { + @Test func `normalize locale strips collation`() { #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") } - @Test func normalizeLocaleStripsUnicodeExtensions() { + @Test func `normalize locale strips unicode extensions`() { #expect(normalizeLocaleIdentifier("de-DE-u-co-phonebk") == "de-DE") #expect(normalizeLocaleIdentifier("ja-JP-t-ja") == "ja-JP") } diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift index 5e5636aee898b..84f6aca0e3fa6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayControllerTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct VoiceWakeOverlayControllerTests { - @Test func overlayControllerLifecycleWithoutUI() async { + @Test func `overlay controller lifecycle without UI`() async { let controller = VoiceWakeOverlayController(enableUI: false) let token = controller.startSession( source: .wakeWord, @@ -31,7 +31,7 @@ struct VoiceWakeOverlayControllerTests { #expect(controller.snapshot().token == nil) } - @Test func evaluateTokenDropsMismatchAndNoActive() { + @Test func `evaluate token drops mismatch and no active`() { let active = UUID() #expect(VoiceWakeOverlayController.evaluateToken(active: nil, incoming: active) == .dropNoActive) #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: UUID()) == .dropMismatch) @@ -39,7 +39,7 @@ struct VoiceWakeOverlayControllerTests { #expect(VoiceWakeOverlayController.evaluateToken(active: active, incoming: nil) == .accept) } - @Test func updateLevelThrottlesRapidChanges() async { + @Test func `update level throttles rapid changes`() async { let controller = VoiceWakeOverlayController(enableUI: false) let token = controller.startSession( source: .wakeWord, @@ -62,7 +62,7 @@ struct VoiceWakeOverlayControllerTests { #expect(controller.model.level == 0.9) } - @Test func overlayControllerExercisesHelpers() async { + @Test func `overlay controller exercises helpers`() async { await VoiceWakeOverlayController.exerciseForTesting() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift index 7e8b0a17f7053..30c2ffc32baa3 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayTests.swift @@ -2,19 +2,19 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct VoiceWakeOverlayTests { - @Test func guardTokenDropsWhenNoActive() { +struct VoiceWakeOverlayTests { + @Test func `guard token drops when no active`() { let outcome = VoiceWakeOverlayController.evaluateToken(active: nil, incoming: UUID()) #expect(outcome == .dropNoActive) } - @Test func guardTokenAcceptsMatching() { + @Test func `guard token accepts matching`() { let token = UUID() let outcome = VoiceWakeOverlayController.evaluateToken(active: token, incoming: token) #expect(outcome == .accept) } - @Test func guardTokenDropsMismatchWithoutDismissing() { + @Test func `guard token drops mismatch without dismissing`() { let outcome = VoiceWakeOverlayController.evaluateToken(active: UUID(), incoming: UUID()) #expect(outcome == .dropMismatch) } diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift index eaec98ab8b848..5c43ff255b39b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeOverlayViewSmokeTests.swift @@ -5,14 +5,14 @@ import Testing @Suite(.serialized) @MainActor struct VoiceWakeOverlayViewSmokeTests { - @Test func overlayViewBuildsBodyInDisplayMode() { + @Test func `overlay view builds body in display mode`() { let controller = VoiceWakeOverlayController(enableUI: false) _ = controller.startSession(source: .wakeWord, transcript: "hello", forwardEnabled: true) let view = VoiceWakeOverlayView(controller: controller) _ = view.body } - @Test func overlayViewBuildsBodyInEditingMode() { + @Test func `overlay view builds body in editing mode`() { let controller = VoiceWakeOverlayController(enableUI: false) let token = controller.startSession(source: .pushToTalk, transcript: "edit me", forwardEnabled: true) controller.userBeganEditing() @@ -21,7 +21,7 @@ struct VoiceWakeOverlayViewSmokeTests { _ = view.body } - @Test func closeButtonOverlayBuildsBody() { + @Test func `close button overlay builds body`() { let view = CloseButtonOverlay(isVisible: true, onHover: { _ in }, onClose: {}) _ = view.body } diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift index 684aec74d4c88..eac7ceea37ded 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift @@ -3,51 +3,51 @@ import SwabbleKit import Testing @testable import OpenClaw -@Suite struct VoiceWakeRuntimeTests { - @Test func trimsAfterTriggerKeepsPostSpeech() { +struct VoiceWakeRuntimeTests { + @Test func `trims after trigger keeps post speech`() { let triggers = ["claude", "openclaw"] let text = "hey Claude how are you" #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "how are you") } - @Test func trimsAfterTriggerReturnsOriginalWhenNoTrigger() { + @Test func `trims after trigger returns original when no trigger`() { let triggers = ["claude"] let text = "good morning friend" #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == text) } - @Test func trimsAfterFirstMatchingTrigger() { + @Test func `trims after first matching trigger`() { let triggers = ["buddy", "claude"] let text = "hello buddy this is after trigger claude also here" #expect(VoiceWakeRuntime ._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here") } - @Test func hasContentAfterTriggerFalseWhenOnlyTrigger() { + @Test func `has content after trigger false when only trigger`() { let triggers = ["openclaw"] let text = "hey openclaw" #expect(!VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) } - @Test func hasContentAfterTriggerTrueWhenSpeechContinues() { + @Test func `has content after trigger true when speech continues`() { let triggers = ["claude"] let text = "claude write a note" #expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) } - @Test func trimsAfterChineseTriggerKeepsPostSpeech() { + @Test func `trims after chinese trigger keeps post speech`() { let triggers = ["小爪", "openclaw"] let text = "嘿 小爪 帮我打开设置" #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "帮我打开设置") } - @Test func trimsAfterTriggerHandlesWidthInsensitiveForms() { + @Test func `trims after trigger handles width insensitive forms`() { let triggers = ["openclaw"] let text = "OpenClaw 请帮我" #expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "请帮我") } - @Test func gateRequiresGapBetweenTriggerAndCommand() { + @Test func `gate requires gap between trigger and command`() { let transcript = "hey openclaw do thing" let segments = makeWakeWordSegments( transcript: transcript, @@ -61,7 +61,7 @@ import Testing #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil) } - @Test func gateAcceptsGapAndExtractsCommand() { + @Test func `gate accepts gap and extracts command`() { let transcript = "hey openclaw do thing" let segments = makeWakeWordSegments( transcript: transcript, diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeTesterTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeTesterTests.swift index cd5436d00d4d6..666587e8cbd5a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeTesterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeTesterTests.swift @@ -3,7 +3,7 @@ import SwabbleKit import Testing struct VoiceWakeTesterTests { - @Test func matchRespectsGapRequirement() { + @Test func `match respects gap requirement`() { let transcript = "hey claude do thing" let segments = makeWakeWordSegments( transcript: transcript, @@ -17,7 +17,7 @@ struct VoiceWakeTesterTests { #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil) } - @Test func matchReturnsCommandAfterGap() { + @Test func `match returns command after gap`() { let transcript = "hey claude do thing" let segments = makeWakeWordSegments( transcript: transcript, diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatMainSessionKeyTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatMainSessionKeyTests.swift index 99dd1f62d409c..75cdb2db84b51 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatMainSessionKeyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatMainSessionKeyTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import OpenClaw -@Suite struct WebChatMainSessionKeyTests { - @Test func configGetSnapshotMainKeyFallsBackToMainWhenMissing() throws { +struct WebChatMainSessionKeyTests { + @Test func `config get snapshot main key falls back to main when missing`() throws { let json = """ { "path": "/Users/pete/.openclaw/openclaw.json", @@ -19,7 +19,7 @@ import Testing #expect(key == "main") } - @Test func configGetSnapshotMainKeyTrimsAndUsesValue() throws { + @Test func `config get snapshot main key trims and uses value`() throws { let json = """ { "path": "/Users/pete/.openclaw/openclaw.json", @@ -35,7 +35,7 @@ import Testing #expect(key == "main") } - @Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws { + @Test func `config get snapshot main key falls back when empty or whitespace`() throws { let json = """ { "config": { "session": { "mainKey": " " } } @@ -45,7 +45,7 @@ import Testing #expect(key == "main") } - @Test func configGetSnapshotMainKeyFallsBackWhenConfigNull() throws { + @Test func `config get snapshot main key falls back when config null`() throws { let json = """ { "config": null @@ -55,7 +55,7 @@ import Testing #expect(key == "main") } - @Test func configGetSnapshotUsesGlobalScope() throws { + @Test func `config get snapshot uses global scope`() throws { let json = """ { "config": { "session": { "scope": "global" } } diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatManagerTests.swift index b7888141825ac..83ce2b7500f89 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatManagerTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.serialized) @MainActor struct WebChatManagerTests { - @Test func preferredSessionKeyIsNonEmpty() async { + @Test func `preferred session key is non empty`() async { let key = await WebChatManager.shared.preferredSessionKey() #expect(!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift index 42fe3b4997698..30f5ae3a34b2c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift @@ -7,7 +7,7 @@ import Testing @Suite(.serialized) @MainActor struct WebChatSwiftUISmokeTests { - private struct TestTransport: OpenClawChatTransport, Sendable { + private struct TestTransport: OpenClawChatTransport { func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { let json = """ {"sessionKey":"\(sessionKey)","sessionId":null,"messages":[],"thinkingLevel":"off"} @@ -41,7 +41,7 @@ struct WebChatSwiftUISmokeTests { func setActiveSessionKey(_: String) async throws {} } - @Test func windowControllerShowAndClose() { + @Test func `window controller show and close`() { let controller = WebChatSwiftUIWindowController( sessionKey: "main", presentation: .window, @@ -50,7 +50,7 @@ struct WebChatSwiftUISmokeTests { controller.close() } - @Test func panelControllerPresentAndClose() { + @Test func `panel controller present and close`() { let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } let controller = WebChatSwiftUIWindowController( sessionKey: "main", diff --git a/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift index 24644a2f10859..0168291aa4603 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift @@ -2,9 +2,8 @@ import Darwin import Testing @testable import OpenClawDiscovery -@Suite struct WideAreaGatewayDiscoveryTests { - @Test func discoversBeaconFromTailnetDnsSdFallback() { + @Test func `discovers beacon from tailnet dns sd fallback`() { setenv("OPENCLAW_WIDE_AREA_DOMAIN", "openclaw.internal", 1) let statusJson = """ { diff --git a/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift b/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift index 0afd3eb5b8819..658eabcabda60 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WindowPlacementTests.swift @@ -2,18 +2,17 @@ import AppKit import Testing @testable import OpenClaw -@Suite @MainActor struct WindowPlacementTests { @Test - func centeredFrameZeroBoundsFallsBackToOrigin() { + func `centered frame zero bounds falls back to origin`() { let frame = WindowPlacement.centeredFrame(size: NSSize(width: 120, height: 80), in: NSRect.zero) #expect(frame.origin == .zero) #expect(frame.size == NSSize(width: 120, height: 80)) } @Test - func centeredFrameClampsToBoundsAndCenters() { + func `centered frame clamps to bounds and centers`() { let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) let frame = WindowPlacement.centeredFrame(size: NSSize(width: 600, height: 120), in: bounds) #expect(frame.size.width == bounds.width) @@ -23,7 +22,7 @@ struct WindowPlacementTests { } @Test - func topRightFrameZeroBoundsFallsBackToOrigin() { + func `top right frame zero bounds falls back to origin`() { let frame = WindowPlacement.topRightFrame( size: NSSize(width: 120, height: 80), padding: 12, @@ -33,7 +32,7 @@ struct WindowPlacementTests { } @Test - func topRightFrameClampsToBoundsAndAppliesPadding() { + func `top right frame clamps to bounds and applies padding`() { let bounds = NSRect(x: 10, y: 20, width: 300, height: 200) let frame = WindowPlacement.topRightFrame( size: NSSize(width: 400, height: 50), @@ -46,7 +45,7 @@ struct WindowPlacementTests { } @Test - func ensureOnScreenUsesFallbackWhenWindowOffscreen() { + func `ensure on screen uses fallback when window offscreen`() { let window = NSWindow( contentRect: NSRect(x: 100_000, y: 100_000, width: 200, height: 120), styleMask: [.borderless], @@ -62,7 +61,7 @@ struct WindowPlacementTests { } @Test - func ensureOnScreenDoesNotMoveVisibleWindow() { + func `ensure on screen does not move visible window`() { let screen = NSScreen.main ?? NSScreen.screens.first #expect(screen != nil) guard let screen else { return } diff --git a/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift index 7817b03d809e3..1e3bb78f3464b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WorkActivityStoreTests.swift @@ -3,10 +3,9 @@ import OpenClawProtocol import Testing @testable import OpenClaw -@Suite @MainActor struct WorkActivityStoreTests { - @Test func mainSessionJobPreemptsOther() { + @Test func `main session job preempts other`() { let store = WorkActivityStore() store.handleJob(sessionKey: "discord:group:1", state: "started") @@ -26,7 +25,7 @@ struct WorkActivityStoreTests { #expect(store.current == nil) } - @Test func jobStaysWorkingAfterToolResultGrace() async { + @Test func `job stays working after tool result grace`() async { let store = WorkActivityStore() store.handleJob(sessionKey: "main", state: "started") @@ -57,7 +56,7 @@ struct WorkActivityStoreTests { #expect(store.iconState == .idle) } - @Test func toolLabelExtractsFirstLineAndShortensHome() { + @Test func `tool label extracts first line and shortens home`() { let store = WorkActivityStore() let home = NSHomeDirectory() @@ -85,7 +84,7 @@ struct WorkActivityStoreTests { #expect(store.iconState == .workingMain(.tool(.read))) } - @Test func resolveIconStateHonorsOverrideSelection() { + @Test func `resolve icon state honors override selection`() { let store = WorkActivityStore() store.handleJob(sessionKey: "main", state: "started") #expect(store.iconState == .workingMain(.job)) From 5f45e76d615f67624b18083e6650109b47d426bb Mon Sep 17 00:00:00 2001 From: daymade Date: Sun, 8 Mar 2026 19:18:38 +0800 Subject: [PATCH 021/311] fix(darwin): remove self-kickstart from launchd gateway restart; rely on KeepAlive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gateway needs a config-triggered restart under launchd, calling `launchctl kickstart -k` from within the service itself races with launchd's async bootout state machine: 1. `kickstart -k` initiates a launchd bootout → SIGTERM to self 2. Gateway ignores SIGTERM during shutdown → process doesn't exit 3. 2s `spawnSync` timeout kills the launchctl child, but launchd continues the bootout asynchronously 4. Fallback `launchctl bootstrap` fails with EIO (service mid-bootout) 5. In-process restart runs on the same PID that launchd will SIGKILL 6. LaunchAgent is permanently unloaded — no auto-restart Fix: on darwin/launchd, skip `triggerOpenClawRestart()` entirely. The caller already calls `exitProcess(0)` for supervised mode, and `KeepAlive=true` (always set in the plist template) restarts the service within ~1 second. The schtasks (Windows) path is unchanged — Windows doesn't have an equivalent KeepAlive mechanism. --- src/infra/process-respawn.test.ts | 35 +++++++++---------------------- src/infra/process-respawn.ts | 8 ++++++- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 4a18a79760732..06ccfff5b9ef2 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -46,16 +46,17 @@ function clearSupervisorHints() { } } -function expectLaunchdKickstartSupervised(params?: { launchJobLabel?: string }) { +function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) { setPlatform("darwin"); if (params?.launchJobLabel) { process.env.LAUNCH_JOB_LABEL = params.launchJobLabel; } process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; - triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); - expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); + // launchd path no longer calls triggerOpenClawRestart — it relies on + // KeepAlive=true to restart the service after the caller exits. + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); } @@ -67,35 +68,19 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); - it("returns supervised when launchd hints are present on macOS", () => { + it("returns supervised when launchd hints are present on macOS (no kickstart)", () => { clearSupervisorHints(); setPlatform("darwin"); process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; - triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" }); const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); - expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce(); + // launchd relies on KeepAlive=true — no kickstart call needed. + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); - it("runs launchd kickstart helper on macOS when launchd label is set", () => { - expectLaunchdKickstartSupervised({ launchJobLabel: "ai.openclaw.gateway" }); - }); - - it("returns failed when launchd kickstart helper fails", () => { - setPlatform("darwin"); - process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; - process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; - triggerOpenClawRestartMock.mockReturnValue({ - ok: false, - method: "launchctl", - detail: "spawn failed", - }); - - const result = restartGatewayProcessWithFreshPid(); - - expect(result.mode).toBe("failed"); - expect(result.detail).toContain("spawn failed"); + it("returns supervised on macOS when launchd label is set (no kickstart)", () => { + expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" }); }); it("does not schedule kickstart on non-darwin platforms", () => { @@ -133,7 +118,7 @@ describe("restartGatewayProcessWithFreshPid", () => { it("returns supervised when OPENCLAW_LAUNCHD_LABEL is set (stock launchd plist)", () => { clearSupervisorHints(); - expectLaunchdKickstartSupervised(); + expectLaunchdSupervisedWithoutKickstart(); }); it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => { diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 0edc43f2de4d1..ad96133ebb6ae 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -30,7 +30,13 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { } const supervisor = detectRespawnSupervisor(process.env); if (supervisor) { - if (supervisor === "launchd" || supervisor === "schtasks") { + // launchd: exit(0) is sufficient — KeepAlive=true restarts the service. + // Calling `kickstart -k` from within the service itself races with + // launchd's async bootout state machine: the spawnSync timeout kills the + // launchctl child, but launchd continues the bootout and eventually + // SIGKILLs this process, leaving the LaunchAgent permanently unloaded. + // See: https://github.com/openclaw/openclaw/issues/39760 + if (supervisor === "schtasks") { const restart = triggerOpenClawRestart(); if (!restart.ok) { return { From 03aea082d05476f0cfba7ca2826b384bba17a540 Mon Sep 17 00:00:00 2001 From: daymade Date: Sun, 8 Mar 2026 19:24:30 +0800 Subject: [PATCH 022/311] chore: condense inline comments per code review Remove redundant rationale from test body (test names already convey it) and trim the production comment to what/consequence/link (mechanism details live in #39760). --- src/infra/process-respawn.test.ts | 3 --- src/infra/process-respawn.ts | 6 ++---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 06ccfff5b9ef2..c2d2d3afd0d22 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -54,8 +54,6 @@ function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: str process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); - // launchd path no longer calls triggerOpenClawRestart — it relies on - // KeepAlive=true to restart the service after the caller exits. expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); } @@ -74,7 +72,6 @@ describe("restartGatewayProcessWithFreshPid", () => { process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway"; const result = restartGatewayProcessWithFreshPid(); expect(result.mode).toBe("supervised"); - // launchd relies on KeepAlive=true — no kickstart call needed. expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); }); diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index ad96133ebb6ae..8bf1503b18f6f 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -31,10 +31,8 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult { const supervisor = detectRespawnSupervisor(process.env); if (supervisor) { // launchd: exit(0) is sufficient — KeepAlive=true restarts the service. - // Calling `kickstart -k` from within the service itself races with - // launchd's async bootout state machine: the spawnSync timeout kills the - // launchctl child, but launchd continues the bootout and eventually - // SIGKILLs this process, leaving the LaunchAgent permanently unloaded. + // Self-issued `kickstart -k` races with launchd's bootout state machine + // and can leave the LaunchAgent permanently unloaded. // See: https://github.com/openclaw/openclaw/issues/39760 if (supervisor === "schtasks") { const restart = triggerOpenClawRestart(); From f930fcbd3f99d185df6ee67693552cf6c8f7030b Mon Sep 17 00:00:00 2001 From: daymade Date: Sun, 8 Mar 2026 19:33:23 +0800 Subject: [PATCH 023/311] Add regression test and CHANGELOG entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test ensuring launchd path never returns "failed" status - Add CHANGELOG.md entry documenting the fix with issue/PR references - Reference ThrottleInterval evolution (#27650 → #29078 → current 1s) --- CHANGELOG.md | 1 + src/infra/process-respawn.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa17671569f44..b78de9fa2ce1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -725,6 +725,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade. - Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting. - Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting. - Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index c2d2d3afd0d22..62091423af73e 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -80,6 +80,22 @@ describe("restartGatewayProcessWithFreshPid", () => { expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" }); }); + it("launchd supervisor never returns failed regardless of triggerOpenClawRestart outcome", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway"; + // Even if triggerOpenClawRestart *would* fail, launchd path must not call it. + triggerOpenClawRestartMock.mockReturnValue({ + ok: false, + method: "launchctl", + detail: "Bootstrap failed: 5: Input/output error", + }); + const result = restartGatewayProcessWithFreshPid(); + expect(result.mode).toBe("supervised"); + expect(result.mode).not.toBe("failed"); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + }); + it("does not schedule kickstart on non-darwin platforms", () => { setPlatform("linux"); process.env.INVOCATION_ID = "abc123"; From f66cc886d372aa0a80861fbb1839c9f1e2651135 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:43:31 +0000 Subject: [PATCH 024/311] test(agents): normalize live model not-found skips --- src/agents/live-model-errors.test.ts | 21 +++++++++++++++ src/agents/live-model-errors.ts | 24 +++++++++++++++++ src/agents/models.profiles.live.test.ts | 36 ++++++++++++------------- 3 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 src/agents/live-model-errors.test.ts create mode 100644 src/agents/live-model-errors.ts diff --git a/src/agents/live-model-errors.test.ts b/src/agents/live-model-errors.test.ts new file mode 100644 index 0000000000000..a0db57799ede2 --- /dev/null +++ b/src/agents/live-model-errors.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + isMiniMaxModelNotFoundErrorMessage, + isModelNotFoundErrorMessage, +} from "./live-model-errors.js"; + +describe("live model error helpers", () => { + it("detects generic model-not-found messages", () => { + expect(isModelNotFoundErrorMessage('{"code":404,"message":"model not found"}')).toBe(true); + expect(isModelNotFoundErrorMessage("model: MiniMax-M2.5-highspeed not found")).toBe(true); + expect(isModelNotFoundErrorMessage("request ended without sending any chunks")).toBe(false); + }); + + it("detects bare minimax 404 page-not-found responses", () => { + expect(isMiniMaxModelNotFoundErrorMessage("404 page not found")).toBe(true); + expect(isMiniMaxModelNotFoundErrorMessage("Error: 404 404 page not found")).toBe(true); + expect(isMiniMaxModelNotFoundErrorMessage("request ended without sending any chunks")).toBe( + false, + ); + }); +}); diff --git a/src/agents/live-model-errors.ts b/src/agents/live-model-errors.ts new file mode 100644 index 0000000000000..56ba30a826bc7 --- /dev/null +++ b/src/agents/live-model-errors.ts @@ -0,0 +1,24 @@ +export function isModelNotFoundErrorMessage(raw: string): boolean { + const msg = raw.trim(); + if (!msg) { + return false; + } + if (/\b404\b/.test(msg) && /not(?:[_\-\s])?found/i.test(msg)) { + return true; + } + if (/not_found_error/i.test(msg)) { + return true; + } + if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not(?:[_\-\s])?found/i.test(msg)) { + return true; + } + return false; +} + +export function isMiniMaxModelNotFoundErrorMessage(raw: string): boolean { + const msg = raw.trim(); + if (!msg) { + return false; + } + return /\b404\b.*\bpage not found\b/i.test(msg); +} diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index c257c24f100a0..6386eaef158da 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -9,6 +9,10 @@ import { isAnthropicBillingError, isAnthropicRateLimitError, } from "./live-auth-keys.js"; +import { + isMiniMaxModelNotFoundErrorMessage, + isModelNotFoundErrorMessage, +} from "./live-model-errors.js"; import { isModernModelRef } from "./live-model-filter.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; @@ -82,23 +86,6 @@ function isGoogleModelNotFoundError(err: unknown): boolean { return false; } -function isModelNotFoundErrorMessage(raw: string): boolean { - const msg = raw.trim(); - if (!msg) { - return false; - } - if (/\b404\b/.test(msg) && /not[_-]?found/i.test(msg)) { - return true; - } - if (/not_found_error/i.test(msg)) { - return true; - } - if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg)) { - return true; - } - return false; -} - function isChatGPTUsageLimitErrorMessage(raw: string): boolean { const msg = raw.toLowerCase(); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); @@ -488,7 +475,11 @@ describeLive("live models (profile keys)", () => { if (ok.res.stopReason === "error") { const msg = ok.res.errorMessage ?? ""; - if (allowNotFoundSkip && isModelNotFoundErrorMessage(msg)) { + if ( + allowNotFoundSkip && + (isModelNotFoundErrorMessage(msg) || + (model.provider === "minimax" && isMiniMaxModelNotFoundErrorMessage(msg))) + ) { skipped.push({ model: id, reason: msg }); logProgress(`${progressLabel}: skip (model not found)`); break; @@ -572,6 +563,15 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (google model not found)`); break; } + if ( + allowNotFoundSkip && + model.provider === "minimax" && + isMiniMaxModelNotFoundErrorMessage(message) + ) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (model not found)`); + break; + } if ( allowNotFoundSkip && model.provider === "minimax" && From 386b811ddd9383060eddc9c3cbdb714b69245119 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:43:32 +0000 Subject: [PATCH 025/311] test(cron): relax concurrent start race timeout --- src/cron/service.issue-regressions.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index 54f341bc7eaa2..b2276aeb39890 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -1463,9 +1463,12 @@ describe("Cron issue regressions", () => { }); const timerPromise = onTimer(state); + // Full-suite parallel runs can briefly delay both workers from starting + // even when `maxConcurrentRuns` is honored, so keep the assertion focused + // on concurrency rather than a sub-100ms scheduler race. const startTimeout = setTimeout(() => { bothRunsStarted.reject(new Error("timed out waiting for concurrent job starts")); - }, 90); + }, 250); try { await bothRunsStarted.promise; } finally { From eebee8409394beef2af93e3892183a78cce4ff09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:43:34 +0000 Subject: [PATCH 026/311] fix(models): discover Vercel AI Gateway catalog --- CHANGELOG.md | 1 + docs/providers/vercel-ai-gateway.md | 2 + src/agents/models-config.e2e-harness.ts | 1 + src/agents/models-config.providers.ts | 17 ++ ...config.providers.vercel-ai-gateway.test.ts | 87 ++++++++ src/agents/vercel-ai-gateway.ts | 197 ++++++++++++++++++ 6 files changed, 305 insertions(+) create mode 100644 src/agents/models-config.providers.vercel-ai-gateway.test.ts create mode 100644 src/agents/vercel-ai-gateway.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b78de9fa2ce1a..8ed3a3077a9df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`. +- Models/Vercel AI Gateway: synthesize the built-in `vercel-ai-gateway` provider from `AI_GATEWAY_API_KEY` and auto-discover the live `/v1/models` catalog so `/models vercel-ai-gateway` exposes current refs including `openai/gpt-5.4`. - Security/Config: fail closed when `loadConfig()` hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone. - Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in `bm25RankToScore()` so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01. - LINE/`requireMention` group gating: align inbound and reply-stage LINE group policy resolution across raw, `group:`, and `room:` keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang. diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 3b5053fbac7ce..f76e2b51bb53c 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -13,6 +13,8 @@ The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to - Provider: `vercel-ai-gateway` - Auth: `AI_GATEWAY_API_KEY` - API: Anthropic Messages compatible +- OpenClaw auto-discovers the Gateway `/v1/models` catalog, so `/models vercel-ai-gateway` + includes current model refs such as `vercel-ai-gateway/openai/gpt-5.4`. ## Quick start diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 2728b6014bf96..4bd6d47960cf2 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -83,6 +83,7 @@ export async function withCopilotGithubToken( } export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ + "AI_GATEWAY_API_KEY", "CLOUDFLARE_AI_GATEWAY_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index f88df714b722a..a98f7bc04464c 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -63,6 +63,7 @@ import { buildTogetherModelDefinition, } from "./together-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -953,6 +954,14 @@ async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { + return { + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + api: "anthropic-messages", + models: await discoverVercelAiGatewayModels(), + }; +} + function buildTogetherProvider(): ProviderConfig { return { baseUrl: TOGETHER_BASE_URL, @@ -1214,6 +1223,14 @@ export async function resolveImplicitProviders(params: { break; } + const vercelAiGatewayKey = resolveProviderApiKey("vercel-ai-gateway").apiKey; + if (vercelAiGatewayKey) { + providers["vercel-ai-gateway"] = { + ...(await buildVercelAiGatewayProvider()), + apiKey: vercelAiGatewayKey, + }; + } + // Ollama provider - auto-discover if running locally, or add if explicitly configured. // Use the user's configured baseUrl (from explicit providers) for model // discovery so that remote / non-default Ollama instances are reachable. diff --git a/src/agents/models-config.providers.vercel-ai-gateway.test.ts b/src/agents/models-config.providers.vercel-ai-gateway.test.ts new file mode 100644 index 0000000000000..c4caf98ce1178 --- /dev/null +++ b/src/agents/models-config.providers.vercel-ai-gateway.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; +import { VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; + +describe("vercel-ai-gateway provider resolution", () => { + it("adds the provider with GPT-5.4 models when AI_GATEWAY_API_KEY is present", async () => { + const envSnapshot = captureEnv(["AI_GATEWAY_API_KEY"]); + process.env.AI_GATEWAY_API_KEY = "vercel-gateway-test-key"; // pragma: allowlist secret + try { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const providers = await resolveImplicitProviders({ agentDir }); + const provider = providers?.["vercel-ai-gateway"]; + expect(provider?.apiKey).toBe("AI_GATEWAY_API_KEY"); + expect(provider?.api).toBe("anthropic-messages"); + expect(provider?.baseUrl).toBe(VERCEL_AI_GATEWAY_BASE_URL); + expect(provider?.models?.some((model) => model.id === "openai/gpt-5.4")).toBe(true); + expect(provider?.models?.some((model) => model.id === "openai/gpt-5.4-pro")).toBe(true); + } finally { + envSnapshot.restore(); + } + }); + + it("prefers env keyRef marker over runtime plaintext for persistence", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["AI_GATEWAY_API_KEY"]); + delete process.env.AI_GATEWAY_API_KEY; + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vercel-ai-gateway:default": { + type: "api_key", + provider: "vercel-ai-gateway", + key: "sk-runtime-vercel", + keyRef: { source: "env", provider: "default", id: "AI_GATEWAY_API_KEY" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["vercel-ai-gateway"]?.apiKey).toBe("AI_GATEWAY_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for non-env keyRef vercel profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vercel-ai-gateway:default": { + type: "api_key", + provider: "vercel-ai-gateway", + key: "sk-runtime-vercel", + keyRef: { source: "file", provider: "vault", id: "/vercel/ai-gateway/api-key" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["vercel-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); +}); diff --git a/src/agents/vercel-ai-gateway.ts b/src/agents/vercel-ai-gateway.ts new file mode 100644 index 0000000000000..a236474708fdc --- /dev/null +++ b/src/agents/vercel-ai-gateway.ts @@ -0,0 +1,197 @@ +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +export const VERCEL_AI_GATEWAY_PROVIDER_ID = "vercel-ai-gateway"; +export const VERCEL_AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; +export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_ID = "anthropic/claude-opus-4.6"; +export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = `${VERCEL_AI_GATEWAY_PROVIDER_ID}/${VERCEL_AI_GATEWAY_DEFAULT_MODEL_ID}`; +export const VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW = 200_000; +export const VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS = 128_000; +export const VERCEL_AI_GATEWAY_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +} as const; + +const log = createSubsystemLogger("agents/vercel-ai-gateway"); + +type VercelPricingShape = { + input?: number | string; + output?: number | string; + input_cache_read?: number | string; + input_cache_write?: number | string; +}; + +type VercelGatewayModelShape = { + id?: string; + name?: string; + context_window?: number; + max_tokens?: number; + tags?: string[]; + pricing?: VercelPricingShape; +}; + +type VercelGatewayModelsResponse = { + data?: VercelGatewayModelShape[]; +}; + +type StaticVercelGatewayModel = Omit & { + cost?: Partial; +}; + +const STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG: readonly StaticVercelGatewayModel[] = [ + { + id: "anthropic/claude-opus-4.6", + name: "Claude Opus 4.6", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_000_000, + maxTokens: 128_000, + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + }, + { + id: "openai/gpt-5.4", + name: "GPT 5.4", + reasoning: true, + input: ["text", "image"], + contextWindow: 200_000, + maxTokens: 128_000, + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + }, + }, + { + id: "openai/gpt-5.4-pro", + name: "GPT 5.4 Pro", + reasoning: true, + input: ["text", "image"], + contextWindow: 200_000, + maxTokens: 128_000, + cost: { + input: 30, + output: 180, + cacheRead: 0, + }, + }, +] as const; + +function toPerMillionCost(value: number | string | undefined): number { + const numeric = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseFloat(value) + : Number.NaN; + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + return numeric * 1_000_000; +} + +function normalizeCost(pricing?: VercelPricingShape): ModelDefinitionConfig["cost"] { + return { + input: toPerMillionCost(pricing?.input), + output: toPerMillionCost(pricing?.output), + cacheRead: toPerMillionCost(pricing?.input_cache_read), + cacheWrite: toPerMillionCost(pricing?.input_cache_write), + }; +} + +function buildStaticModelDefinition(model: StaticVercelGatewayModel): ModelDefinitionConfig { + return { + id: model.id, + name: model.name, + reasoning: model.reasoning, + input: model.input, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + cost: { + ...VERCEL_AI_GATEWAY_DEFAULT_COST, + ...model.cost, + }, + }; +} + +function getStaticFallbackModel(id: string): ModelDefinitionConfig | undefined { + const fallback = STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG.find((model) => model.id === id); + return fallback ? buildStaticModelDefinition(fallback) : undefined; +} + +export function getStaticVercelAiGatewayModelCatalog(): ModelDefinitionConfig[] { + return STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG.map(buildStaticModelDefinition); +} + +function buildDiscoveredModelDefinition( + model: VercelGatewayModelShape, +): ModelDefinitionConfig | null { + const id = typeof model.id === "string" ? model.id.trim() : ""; + if (!id) { + return null; + } + + const fallback = getStaticFallbackModel(id); + const contextWindow = + typeof model.context_window === "number" && Number.isFinite(model.context_window) + ? model.context_window + : (fallback?.contextWindow ?? VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW); + const maxTokens = + typeof model.max_tokens === "number" && Number.isFinite(model.max_tokens) + ? model.max_tokens + : (fallback?.maxTokens ?? VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS); + const normalizedCost = normalizeCost(model.pricing); + + return { + id, + name: (typeof model.name === "string" ? model.name.trim() : "") || fallback?.name || id, + reasoning: + Array.isArray(model.tags) && model.tags.includes("reasoning") + ? true + : (fallback?.reasoning ?? false), + input: Array.isArray(model.tags) + ? model.tags.includes("vision") + ? ["text", "image"] + : ["text"] + : (fallback?.input ?? ["text"]), + contextWindow, + maxTokens, + cost: + normalizedCost.input > 0 || + normalizedCost.output > 0 || + normalizedCost.cacheRead > 0 || + normalizedCost.cacheWrite > 0 + ? normalizedCost + : (fallback?.cost ?? VERCEL_AI_GATEWAY_DEFAULT_COST), + }; +} + +export async function discoverVercelAiGatewayModels(): Promise { + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return getStaticVercelAiGatewayModelCatalog(); + } + + try { + const response = await fetch(`${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + log.warn(`Failed to discover Vercel AI Gateway models: HTTP ${response.status}`); + return getStaticVercelAiGatewayModelCatalog(); + } + const data = (await response.json()) as VercelGatewayModelsResponse; + const discovered = (data.data ?? []) + .map(buildDiscoveredModelDefinition) + .filter((entry): entry is ModelDefinitionConfig => entry !== null); + return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog(); + } catch (error) { + log.warn(`Failed to discover Vercel AI Gateway models: ${String(error)}`); + return getStaticVercelAiGatewayModelCatalog(); + } +} From 58ae5582f4cd829563fc917216ccb5df4da2ccf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Hellstr=C3=B6m?= <30758862+fellanH@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:00:46 +0100 Subject: [PATCH 027/311] macOS: fix VoiceWakeOverlayController exclusivity violation #39275 --- apps/macos/Sources/OpenClaw/NotifyOverlay.swift | 4 +++- apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift | 5 ++--- apps/macos/Sources/OpenClaw/TalkOverlay.swift | 4 +++- .../Sources/OpenClaw/VoiceWakeOverlayController+Window.swift | 4 +++- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift index d432f5a9a8ec7..280b7396a1588 100644 --- a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift +++ b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift @@ -61,9 +61,11 @@ final class NotifyOverlayController { self.ensureWindow() self.hostingView?.rootView = NotifyOverlayView(controller: self) let target = self.targetFrame() + let isFirst = !self.model.isVisible + if isFirst { self.model.isVisible = true } OverlayPanelFactory.present( window: self.window, - isVisible: &self.model.isVisible, + isFirstPresent: isFirst, target: target) { window in self.updateWindowFrame(animate: true) diff --git a/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift index 95f9ae8638d61..53898cf27b07b 100644 --- a/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift +++ b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift @@ -64,15 +64,14 @@ enum OverlayPanelFactory { @MainActor static func present( window: NSWindow?, - isVisible: inout Bool, + isFirstPresent: Bool, target: NSRect, startOffsetY: CGFloat = -6, onFirstPresent: (() -> Void)? = nil, onAlreadyVisible: (NSWindow) -> Void) { guard let window else { return } - if !isVisible { - isVisible = true + if isFirstPresent { onFirstPresent?() let start = target.offsetBy(dx: 0, dy: startOffsetY) self.animatePresent(window: window, from: start, to: target) diff --git a/apps/macos/Sources/OpenClaw/TalkOverlay.swift b/apps/macos/Sources/OpenClaw/TalkOverlay.swift index f72871d28cae6..660a615c79863 100644 --- a/apps/macos/Sources/OpenClaw/TalkOverlay.swift +++ b/apps/macos/Sources/OpenClaw/TalkOverlay.swift @@ -30,9 +30,11 @@ final class TalkOverlayController { self.ensureWindow() self.hostingView?.rootView = TalkOverlayView(controller: self) let target = self.targetFrame() + let isFirst = !self.model.isVisible + if isFirst { self.model.isVisible = true } OverlayPanelFactory.present( window: self.window, - isVisible: &self.model.isVisible, + isFirstPresent: isFirst, target: target) { window in window.setFrame(target, display: true) diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift index 9575dde52bb83..23133811e80e8 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift @@ -13,9 +13,11 @@ extension VoiceWakeOverlayController { self.ensureWindow() self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) let target = self.targetFrame() + let isFirst = !self.model.isVisible + if isFirst { self.model.isVisible = true } OverlayPanelFactory.present( window: self.window, - isVisible: &self.model.isVisible, + isFirstPresent: isFirst, target: target, onFirstPresent: { self.logger.log( From 374001c4a077212f77cc56c8defb5954d6e4fdf8 Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Sun, 8 Mar 2026 09:09:09 -0400 Subject: [PATCH 028/311] fix: add implicit openai-codex provider snapshot --- ...dels-config.providers.openai-codex.test.ts | 107 ++++++++++++++++++ src/agents/models-config.providers.ts | 15 +++ src/agents/models-config.ts | 2 + 3 files changed, 124 insertions(+) create mode 100644 src/agents/models-config.providers.openai-codex.test.ts diff --git a/src/agents/models-config.providers.openai-codex.test.ts b/src/agents/models-config.providers.openai-codex.test.ts new file mode 100644 index 0000000000000..20df80e1885dc --- /dev/null +++ b/src/agents/models-config.providers.openai-codex.test.ts @@ -0,0 +1,107 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + installModelsConfigTestHooks, + MODELS_CONFIG_IMPLICIT_ENV_VARS, + unsetEnv, + withModelsTempHome, + withTempEnv, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +async function writeCodexOauthProfile(agentDir: string) { + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + order: { + "openai-codex": ["openai-codex:default"], + }, + }, + null, + 2, + ), + "utf8", + ); +} + +describe("openai-codex implicit provider", () => { + it("injects an implicit provider when Codex OAuth exists", async () => { + await withModelsTempHome(async () => { + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const agentDir = resolveOpenClawAgentDir(); + await writeCodexOauthProfile(agentDir); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["openai-codex"]).toMatchObject({ + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + models: [], + }); + }); + }); + }); + + it("replaces stale openai-codex baseUrl in generated models.json", async () => { + await withModelsTempHome(async () => { + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const agentDir = resolveOpenClawAgentDir(); + await writeCodexOauthProfile(agentDir); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + "openai-codex": { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + contextWindow: 1_000_000, + maxTokens: 100_000, + }, + ], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({}); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers["openai-codex"]).toMatchObject({ + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + }); + }); + }); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a98f7bc04464c..eac89af35019b 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -207,6 +207,8 @@ const NVIDIA_DEFAULT_COST = { cacheWrite: 0, }; +const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + const log = createSubsystemLogger("agents/model-providers"); interface OllamaModel { @@ -994,6 +996,14 @@ function buildOpenrouterProvider(): ProviderConfig { }; } +function buildOpenAICodexProvider(): ProviderConfig { + return { + baseUrl: OPENAI_CODEX_BASE_URL, + api: "openai-codex-responses", + models: [], + }; +} + async function buildVllmProvider(params?: { baseUrl?: string; apiKey?: string; @@ -1302,6 +1312,11 @@ export async function resolveImplicitProviders(params: { providers.openrouter = { ...buildOpenrouterProvider(), apiKey: openrouterKey }; } + const openaiCodexProfiles = listProfilesForProvider(authStore, "openai-codex"); + if (openaiCodexProfiles.length > 0) { + providers["openai-codex"] = buildOpenAICodexProvider(); + } + const nvidiaKey = resolveProviderApiKey("nvidia").apiKey; if (nvidiaKey) { providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey }; diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index cb4c76cfe56ed..c67c00549d3bc 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -22,6 +22,7 @@ type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; const MODELS_JSON_WRITE_LOCKS = new Map>(); +const AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS = new Set(["openai-codex"]); function isPositiveFiniteTokenLimit(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; @@ -198,6 +199,7 @@ function mergeWithExistingProviderSecrets(params: { preserved.apiKey = existing.apiKey; } if ( + !AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS.has(key) && !explicitBaseUrlProviders.has(key) && typeof existing.baseUrl === "string" && existing.baseUrl From ec75643a0904dded9fbdbd17287b7d7c1e4185b9 Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Sun, 8 Mar 2026 09:32:21 -0400 Subject: [PATCH 029/311] Models: scope implicit codex baseUrl override --- ...dels-config.providers.openai-codex.test.ts | 48 +++++++++++++++++++ src/agents/models-config.providers.ts | 2 + src/agents/models-config.ts | 28 ++++++++--- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/agents/models-config.providers.openai-codex.test.ts b/src/agents/models-config.providers.openai-codex.test.ts index 20df80e1885dc..596f858b9c059 100644 --- a/src/agents/models-config.providers.openai-codex.test.ts +++ b/src/agents/models-config.providers.openai-codex.test.ts @@ -56,6 +56,7 @@ describe("openai-codex implicit provider", () => { api: "openai-codex-responses", models: [], }); + expect(providers?.["openai-codex"]).not.toHaveProperty("apiKey"); }); }); }); @@ -104,4 +105,51 @@ describe("openai-codex implicit provider", () => { }); }); }); + + it("preserves an existing baseUrl for explicit openai-codex config without oauth synthesis", async () => { + await withModelsTempHome(async () => { + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + models: [], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + "openai-codex": { + api: "openai-codex-responses", + models: [], + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers["openai-codex"]).toMatchObject({ + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + }); + }); + }); + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index eac89af35019b..9232e45ae8523 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1000,6 +1000,8 @@ function buildOpenAICodexProvider(): ProviderConfig { return { baseUrl: OPENAI_CODEX_BASE_URL, api: "openai-codex-responses", + // Like Copilot, Codex resolves OAuth credentials from auth-profiles at + // runtime, so the snapshot only needs the canonical API surface. models: [], }; } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index c67c00549d3bc..89a791747ccc9 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -142,10 +142,16 @@ async function readJson(pathname: string): Promise { async function resolveProvidersForModelsJson(params: { cfg: OpenClawConfig; agentDir: string; -}): Promise> { +}): Promise<{ + providers: Record; + authoritativeImplicitBaseUrlProviders: ReadonlySet; +}> { const { cfg, agentDir } = params; const explicitProviders = cfg.models?.providers ?? {}; const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders }); + const authoritativeImplicitBaseUrlProviders = new Set( + [...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) => Boolean(implicitProviders[key])), + ); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders, @@ -163,7 +169,7 @@ async function resolveProvidersForModelsJson(params: { if (implicitCopilot && !providers["github-copilot"]) { providers["github-copilot"] = implicitCopilot; } - return providers; + return { providers, authoritativeImplicitBaseUrlProviders }; } function mergeWithExistingProviderSecrets(params: { @@ -171,9 +177,15 @@ function mergeWithExistingProviderSecrets(params: { existingProviders: Record[string]>; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; + authoritativeImplicitBaseUrlProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = - params; + const { + nextProviders, + existingProviders, + secretRefManagedProviders, + explicitBaseUrlProviders, + authoritativeImplicitBaseUrlProviders, + } = params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -199,7 +211,7 @@ function mergeWithExistingProviderSecrets(params: { preserved.apiKey = existing.apiKey; } if ( - !AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS.has(key) && + !authoritativeImplicitBaseUrlProviders.has(key) && !explicitBaseUrlProviders.has(key) && typeof existing.baseUrl === "string" && existing.baseUrl @@ -217,6 +229,7 @@ async function resolveProvidersForMode(params: { providers: Record; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; + authoritativeImplicitBaseUrlProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -234,6 +247,7 @@ async function resolveProvidersForMode(params: { existingProviders, secretRefManagedProviders: params.secretRefManagedProviders, explicitBaseUrlProviders: params.explicitBaseUrlProviders, + authoritativeImplicitBaseUrlProviders: params.authoritativeImplicitBaseUrlProviders, }); } @@ -300,7 +314,8 @@ export async function ensureOpenClawModelsJson( // through the full loadConfig() pipeline which applies these. applyConfigEnvVars(cfg); - const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); + const { providers, authoritativeImplicitBaseUrlProviders } = + await resolveProvidersForModelsJson({ cfg, agentDir }); if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; @@ -331,6 +346,7 @@ export async function ensureOpenClawModelsJson( providers: normalizedProviders, secretRefManagedProviders, explicitBaseUrlProviders, + authoritativeImplicitBaseUrlProviders, }); const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; const existingRaw = await readRawFile(targetPath); From e9d51d874b207936e24f5b5a5224f55abf98857c Mon Sep 17 00:00:00 2001 From: Kros Dai Date: Sun, 8 Mar 2026 09:39:40 -0400 Subject: [PATCH 030/311] Models: fix codex follow-up CI issues --- src/agents/models-config.providers.openai-codex.test.ts | 1 + src/agents/models-config.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agents/models-config.providers.openai-codex.test.ts b/src/agents/models-config.providers.openai-codex.test.ts index 596f858b9c059..98051eb05dadc 100644 --- a/src/agents/models-config.providers.openai-codex.test.ts +++ b/src/agents/models-config.providers.openai-codex.test.ts @@ -135,6 +135,7 @@ describe("openai-codex implicit provider", () => { mode: "merge", providers: { "openai-codex": { + baseUrl: "", api: "openai-codex-responses", models: [], }, diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 89a791747ccc9..f891f5a65a4a2 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -150,7 +150,9 @@ async function resolveProvidersForModelsJson(params: { const explicitProviders = cfg.models?.providers ?? {}; const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders }); const authoritativeImplicitBaseUrlProviders = new Set( - [...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) => Boolean(implicitProviders[key])), + [...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) => + Boolean(implicitProviders?.[key]), + ), ); const providers: Record = mergeProviders({ implicit: implicitProviders, From c9f2d6b761e5ea2719b7520fc895976663a68130 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Sun, 8 Mar 2026 11:08:37 +0000 Subject: [PATCH 031/311] fix(agents): let forward-compat resolve api when inline model omits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user configures `models.providers.openai-codex` with a models array but omits the `api` field, `buildInlineProviderModels` produces an entry with `api: undefined`. The inline-match early return then hands this incomplete model straight to the caller, skipping the forward-compat resolver that would supply the correct `openai-codex-responses` api — causing a crash loop. Let the inline match fall through to forward-compat when `api` is absent so the resolver chain can fill it in. Fixes #39682 --- src/agents/pi-embedded-runner/model.test.ts | 26 +++++++++++++++++++++ src/agents/pi-embedded-runner/model.ts | 7 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index ca12a76cb363f..766765c415e41 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -638,6 +638,32 @@ describe("resolveModel", () => { }); }); + it("uses codex fallback when inline model omits api (#39682)", () => { + mockOpenAICodexTemplateModel(); + + // When a user lists gpt-5.4 under openai-codex models without specifying + // an api, the inline match must not shadow the forward-compat resolver + // that supplies "openai-codex-responses". + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://custom.example.com", + models: [{ id: "gpt-5.4" }], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent", cfg); + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + api: "openai-codex-responses", + id: "gpt-5.4", + provider: "openai-codex", + }); + }); + it("includes auth hint for unknown ollama models (#17328)", () => { // resetMockDiscoverModels() in beforeEach already sets find → null const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent"); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index f1b31a5e49aa1..efb3112bd7748 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -161,7 +161,12 @@ export function resolveModelWithRegistry(params: { (entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId, ); if (inlineMatch) { - return normalizeModelCompat(inlineMatch as Model); + // When the inline model already carries a concrete api, use it as-is. + // Otherwise fall through so forward-compat resolvers can supply the + // correct api (e.g. "openai-codex-responses" for gpt-5.4). #39682 + if (inlineMatch.api) { + return normalizeModelCompat(inlineMatch as Model); + } } // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. From 6e086a5b3b097a07aa310abb549ce9fb06257233 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Sun, 8 Mar 2026 11:18:45 +0000 Subject: [PATCH 032/311] chore: update secrets baseline line numbers --- .secrets.baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.secrets.baseline b/.secrets.baseline index 2e441d4608561..15df9508431d1 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -11583,7 +11583,7 @@ "filename": "src/agents/pi-embedded-runner/model.ts", "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", "is_verified": false, - "line_number": 267 + "line_number": 272 } ], "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ From d2347ed82560e0169167e80c60b0f8e69783841c Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sun, 8 Feb 2026 16:14:41 +0100 Subject: [PATCH 033/311] macOS: set speech recognition taskHint for Talk Mode mic capture Add taskHint = .dictation to Talk Mode's SFSpeechAudioBufferRecognitionRequest, matching what Voice Wake already sets. Without this hint the recognizer may not properly initialize audio capture, causing Talk Mode to appear unresponsive. Co-Authored-By: dmiv --- apps/macos/Sources/OpenClaw/TalkModeRuntime.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index a8d8008c65308..56d44ed3ee673 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -178,6 +178,7 @@ actor TalkModeRuntime { self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest?.shouldReportPartialResults = true + self.recognitionRequest?.taskHint = .dictation guard let request = self.recognitionRequest else { return } if self.audioEngine == nil { From d91d24e41d9cb6808e162e5414867f1b202943cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:54:03 +0000 Subject: [PATCH 034/311] refactor: tighten codex inline api fallback follow-up --- src/agents/pi-embedded-runner/model.test.ts | 6 +++--- src/agents/pi-embedded-runner/model.ts | 9 ++------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 766765c415e41..0dfde212a0a56 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -641,14 +641,12 @@ describe("resolveModel", () => { it("uses codex fallback when inline model omits api (#39682)", () => { mockOpenAICodexTemplateModel(); - // When a user lists gpt-5.4 under openai-codex models without specifying - // an api, the inline match must not shadow the forward-compat resolver - // that supplies "openai-codex-responses". const cfg: OpenClawConfig = { models: { providers: { "openai-codex": { baseUrl: "https://custom.example.com", + headers: { "X-Custom-Auth": "token-123" }, models: [{ id: "gpt-5.4" }], }, }, @@ -659,6 +657,8 @@ describe("resolveModel", () => { expect(result.error).toBeUndefined(); expect(result.model).toMatchObject({ api: "openai-codex-responses", + baseUrl: "https://custom.example.com", + headers: { "X-Custom-Auth": "token-123" }, id: "gpt-5.4", provider: "openai-codex", }); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index efb3112bd7748..38d554a2bab90 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -160,13 +160,8 @@ export function resolveModelWithRegistry(params: { const inlineMatch = inlineModels.find( (entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId, ); - if (inlineMatch) { - // When the inline model already carries a concrete api, use it as-is. - // Otherwise fall through so forward-compat resolvers can supply the - // correct api (e.g. "openai-codex-responses" for gpt-5.4). #39682 - if (inlineMatch.api) { - return normalizeModelCompat(inlineMatch as Model); - } + if (inlineMatch?.api) { + return normalizeModelCompat(inlineMatch as Model); } // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. From 38f4ac5e3c9d17a24240097689f1f3c0d61e3d89 Mon Sep 17 00:00:00 2001 From: Ayane Date: Sun, 8 Mar 2026 20:17:29 +0800 Subject: [PATCH 035/311] fix(feishu): restore @larksuiteoapi/node-sdk in root dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled Feishu extension fails to load after npm global install because `@larksuiteoapi/node-sdk` was removed from the root package.json in e1503349c ("scope extension runtime deps to plugin manifests"). Bundled extensions shipped inside the npm package resolve modules through the root node_modules tree. Since `.gitignore` excludes nested `node_modules/` directories, the extension-level `node_modules/` is never published, so the module is unreachable at runtime. Other bundled channel dependencies (e.g. `@discordjs/voice`, `@slack/bolt`) remain in the root manifest for the same reason. Re-add the entry — matching the version already declared in `extensions/feishu/package.json` — so that both global npm installs and the bundled extension path can locate the SDK. Closes #39733 --- package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/package.json b/package.json index f50f3b5fb2fc2..ed248de2fa969 100644 --- a/package.json +++ b/package.json @@ -340,6 +340,7 @@ "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@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.55.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11144b4fd7717..de39720b6a966 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@homebridge/ciao': specifier: ^1.3.5 version: 1.3.5 + '@larksuiteoapi/node-sdk': + specifier: ^1.59.0 + version: 1.59.0 '@line/bot-sdk': specifier: ^10.6.0 version: 10.6.0 From 8a1015f1aadbd619737c33c4ccc0b035e284a159 Mon Sep 17 00:00:00 2001 From: Thirumalesh Date: Wed, 4 Mar 2026 11:21:54 +0530 Subject: [PATCH 036/311] feat: add Brave Search LLM Context API mode for web_search Add support for Brave's LLM Context API endpoint (/res/v1/llm/context) as an optional mode for the web_search tool. When configured with tools.web.search.brave.mode set to llm-context, the tool returns pre-extracted page content optimized for LLM grounding instead of standard URL/snippet results. The llm-context cache key excludes count and ui_lang parameters that the LLM Context API does not accept, preventing unnecessary cache misses. Closes #14992 Co-Authored-By: Claude Opus 4.6 --- src/agents/tools/web-search.test.ts | 23 +++++ src/agents/tools/web-search.ts | 138 ++++++++++++++++++++++++- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.tools.ts | 5 + src/config/zod-schema.agent-runtime.ts | 6 ++ 6 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 7e8f696e88315..de52d6bb0e372 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -15,6 +15,7 @@ const { resolveKimiModel, resolveKimiBaseUrl, extractKimiCitations, + resolveBraveMode, } = __testing; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); @@ -276,3 +277,25 @@ describe("extractKimiCitations", () => { ).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]); }); }); + +describe("resolveBraveMode", () => { + it("defaults to 'web' when no config is provided", () => { + expect(resolveBraveMode({})).toBe("web"); + }); + + it("defaults to 'web' when mode is undefined", () => { + expect(resolveBraveMode({ mode: undefined })).toBe("web"); + }); + + it("returns 'llm-context' when configured", () => { + expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context"); + }); + + it("returns 'web' when mode is explicitly 'web'", () => { + expect(resolveBraveMode({ mode: "web" })).toBe("web"); + }); + + it("falls back to 'web' for unrecognized mode values", () => { + expect(resolveBraveMode({ mode: "invalid" })).toBe("web"); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1e4983f85e2f9..c70f25604f0ee 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -26,6 +26,7 @@ const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; +const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; @@ -247,6 +248,17 @@ type BraveSearchResponse = { }; }; +type BraveLlmContextSnippet = { text: string }; +type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] }; +type BraveLlmContextResponse = { + grounding: { generic?: BraveLlmContextResult[] }; + sources?: { url?: string; hostname?: string; date?: string }[]; +}; + +type BraveConfig = { + mode?: string; +}; + type PerplexityConfig = { apiKey?: string; }; @@ -550,6 +562,21 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE return "perplexity"; } +function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { + if (!search || typeof search !== "object") { + return {}; + } + const brave = "brave" in search ? search.brave : undefined; + if (!brave || typeof brave !== "object") { + return {}; + } + return brave as BraveConfig; +} + +function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { + return brave.mode === "llm-context" ? "llm-context" : "web"; +} + function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { if (!search || typeof search !== "object") { return {}; @@ -1213,6 +1240,67 @@ async function runKimiSearch(params: { }; } +async function runBraveLlmContextSearch(params: { + query: string; + apiKey: string; + timeoutSeconds: number; + country?: string; + search_lang?: string; + freshness?: string; +}): Promise<{ + results: Array<{ + url: string; + title: string; + snippets: string[]; + siteName?: string; + }>; + sources?: BraveLlmContextResponse["sources"]; +}> { + const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT); + url.searchParams.set("q", params.query); + if (params.country) { + url.searchParams.set("country", params.country); + } + if (params.search_lang) { + url.searchParams.set("search_lang", params.search_lang); + } + if (params.freshness) { + url.searchParams.set("freshness", params.freshness); + } + + return withTrustedWebSearchEndpoint( + { + url: url.toString(), + timeoutSeconds: params.timeoutSeconds, + init: { + method: "GET", + headers: { + Accept: "application/json", + "X-Subscription-Token": params.apiKey, + }, + }, + }, + async (res) => { + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as BraveLlmContextResponse; + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + const mapped = genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean), + siteName: resolveSiteName(entry.url) || undefined, + })); + + return { results: mapped, sources: data.sources }; + }, + ); +} + async function runWebSearch(params: { query: string; count: number; @@ -1235,7 +1323,9 @@ async function runWebSearch(params: { geminiModel?: string; kimiBaseUrl?: string; kimiModel?: string; + braveMode?: "web" | "llm-context"; }): Promise> { + const effectiveBraveMode = params.braveMode ?? "web"; const providerSpecificKey = params.provider === "grok" ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` @@ -1245,7 +1335,9 @@ async function runWebSearch(params: { ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` : ""; const cacheKey = normalizeCacheKey( - `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, + params.provider === "brave" && effectiveBraveMode === "llm-context" + ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` + : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) { @@ -1372,6 +1464,42 @@ async function runWebSearch(params: { throw new Error("Unsupported web search provider."); } + if (effectiveBraveMode === "llm-context") { + const { results: llmResults, sources } = await runBraveLlmContextSearch({ + query: params.query, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + country: params.country, + search_lang: params.search_lang, + freshness: params.freshness, + }); + + const mapped = llmResults.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")), + siteName: entry.siteName, + })); + + const payload = { + query: params.query, + provider: params.provider, + mode: "llm-context" as const, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + sources, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + const url = new URL(BRAVE_SEARCH_ENDPOINT); url.searchParams.set("q", params.query); url.searchParams.set("count", String(params.count)); @@ -1465,6 +1593,8 @@ export function createWebSearchTool(options?: { const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); const kimiConfig = resolveKimiConfig(search); + const braveConfig = resolveBraveConfig(search); + const braveMode = resolveBraveMode(braveConfig); const description = provider === "perplexity" @@ -1475,7 +1605,9 @@ export function createWebSearchTool(options?: { ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." : provider === "gemini" ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + : braveMode === "llm-context" + ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; return { label: "Web Search", @@ -1660,6 +1792,7 @@ export function createWebSearchTool(options?: { geminiModel: resolveGeminiModel(geminiConfig), kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), kimiModel: resolveKimiModel(kimiConfig), + braveMode, }); return jsonResult(result); }, @@ -1684,4 +1817,5 @@ export const __testing = { resolveKimiBaseUrl, extractKimiCitations, resolveRedirectUrl: resolveCitationRedirectUrl, + resolveBraveMode, } as const; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 7d04ab5a93a37..cab191a1c0489 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -666,6 +666,8 @@ export const FIELD_HELP: Record = { "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", "tools.web.search.perplexity.model": 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.search.brave.mode": + 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.maxCharsCap": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9266516b95709..40169d3ef94f4 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -224,6 +224,7 @@ export const FIELD_LABELS: Record = { "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret "tools.web.search.grok.model": "Grok Search Model", + "tools.web.search.brave.mode": "Brave Search Mode", "tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret "tools.web.search.kimi.baseUrl": "Kimi Search Base URL", "tools.web.search.kimi.model": "Kimi Search Model", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 5c8152f0e593f..e895e3bcf4f4f 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -485,6 +485,11 @@ export type ToolsConfig = { /** Model to use (defaults to "moonshot-v1-128k"). */ model?: string; }; + /** Brave-specific configuration (used when provider="brave"). */ + brave?: { + /** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */ + mode?: "web" | "llm-context"; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 227891711bbad..421f8febffcc1 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -308,6 +308,12 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + brave: z + .object({ + mode: z.union([z.literal("web"), z.literal("llm-context")]).optional(), + }) + .strict() + .optional(), }) .strict() .optional(); From acac7e31329b5df9349e3927aef06f2c4bd6fbb3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:54:50 +0000 Subject: [PATCH 037/311] fix: land Brave llm-context gaps (#33383) (thanks @thirumaleshp) --- CHANGELOG.md | 2 +- docs/tools/web.md | 26 +++ src/agents/tools/web-search.ts | 24 +++ .../tools/web-tools.enabled-defaults.test.ts | 192 +++++++++++++++++- src/config/config.web-search-provider.test.ts | 26 +++ 5 files changed, 265 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed3a3077a9df..eb7c0d4f82c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - +- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. ### Fixes - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. diff --git a/docs/tools/web.md b/docs/tools/web.md index 3026f5ff1c59a..df1918056ab2e 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -111,6 +111,29 @@ Brave provides paid plans; check the Brave API portal for the current limits and } ``` +**Brave LLM Context mode:** + +```json5 +{ + tools: { + web: { + search: { + enabled: true, + provider: "brave", + apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret + brave: { + mode: "llm-context", + }, + }, + }, + }, +} +``` + +`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets. +In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`, +`freshness`, `date_after`, and `date_before` are rejected. + ## Using Gemini (Google Search grounding) Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), @@ -247,6 +270,9 @@ await web_search({ }); ``` +When Brave `llm-context` mode is enabled, `ui_lang`, `freshness`, `date_after`, and +`date_before` are not supported. Use Brave `web` mode for those filters. + ## web_fetch Fetch a URL and extract readable content. diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index c70f25604f0ee..30327b6dadaaa 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1682,6 +1682,14 @@ export function createWebSearchTool(options?: { } const resolvedSearchLang = normalizedBraveLanguageParams.search_lang; const resolvedUiLang = normalizedBraveLanguageParams.ui_lang; + if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_ui_lang", + message: + "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } const rawFreshness = readStringParam(params, "freshness"); if (rawFreshness && provider !== "brave" && provider !== "perplexity") { return jsonResult({ @@ -1690,6 +1698,14 @@ export function createWebSearchTool(options?: { docs: "https://docs.openclaw.ai/tools/web", }); } + if (rawFreshness && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_freshness", + message: + "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined; if (rawFreshness && !freshness) { return jsonResult({ @@ -1715,6 +1731,14 @@ export function createWebSearchTool(options?: { docs: "https://docs.openclaw.ai/tools/web", }); } + if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") { + return jsonResult({ + error: "unsupported_date_filter", + message: + "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; if (rawDateAfter && !dateAfter) { return jsonResult({ diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index befffcf6fce77..383ce7b9e837f 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -31,6 +31,23 @@ function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string }) { }); } +function createBraveSearchTool(braveConfig?: { mode?: "web" | "llm-context" }) { + return createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + apiKey: "brave-config-test", // pragma: allowlist secret + ...(braveConfig ? { brave: braveConfig } : {}), + }, + }, + }, + }, + sandboxed: true, + }); +} + function createKimiSearchTool(kimiConfig?: { apiKey?: string; baseUrl?: string; model?: string }) { return createWebSearchTool({ config: { @@ -162,7 +179,7 @@ describe("web_search country and language parameters", () => { }>, ) { const mockFetch = installMockFetch({ web: { results: [] } }); - const tool = createWebSearchTool({ config: undefined, sandboxed: true }); + const tool = createBraveSearchTool(); expect(tool).not.toBeNull(); await tool?.execute?.("call-1", { query: "test", ...params }); expect(mockFetch).toHaveBeenCalled(); @@ -180,7 +197,7 @@ describe("web_search country and language parameters", () => { it("should pass language parameter to Brave API as search_lang", async () => { const mockFetch = installMockFetch({ web: { results: [] } }); - const tool = createWebSearchTool({ config: undefined, sandboxed: true }); + const tool = createBraveSearchTool(); await tool?.execute?.("call-1", { query: "test", language: "de" }); const url = new URL(mockFetch.mock.calls[0][0] as string); @@ -204,7 +221,7 @@ describe("web_search country and language parameters", () => { it("rejects unsupported Brave search_lang values before upstream request", async () => { const mockFetch = installMockFetch({ web: { results: [] } }); - const tool = createWebSearchTool({ config: undefined, sandboxed: true }); + const tool = createBraveSearchTool(); const result = await tool?.execute?.("call-1", { query: "test", search_lang: "xx" }); expect(mockFetch).not.toHaveBeenCalled(); @@ -511,8 +528,27 @@ describe("web_search external content wrapping", () => { return mock; } + function installBraveLlmContextFetch( + result: Record, + mock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + grounding: { + generic: [result], + }, + sources: [{ url: "https://example.com/ctx", hostname: "example.com" }], + }), + } as Response), + ), + ) { + global.fetch = withFetchPreconnect(mock); + return mock; + } + async function executeBraveSearch(query: string) { - const tool = createWebSearchTool({ config: undefined, sandboxed: true }); + const tool = createBraveSearchTool(); return tool?.execute?.("call-1", { query }); } @@ -545,6 +581,154 @@ describe("web_search external content wrapping", () => { }); }); + it("uses Brave llm-context endpoint when mode is configured", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch({ + title: "Context title", + url: "https://example.com/ctx", + snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }], + }); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + brave: { + mode: "llm-context", + }, + }, + }, + }, + }, + sandboxed: true, + }); + const result = await tool?.execute?.("call-1", { + query: "llm-context test", + country: "DE", + search_lang: "de", + }); + + const requestUrl = new URL(mockFetch.mock.calls[0]?.[0] as string); + expect(requestUrl.pathname).toBe("/res/v1/llm/context"); + expect(requestUrl.searchParams.get("q")).toBe("llm-context test"); + expect(requestUrl.searchParams.get("country")).toBe("DE"); + expect(requestUrl.searchParams.get("search_lang")).toBe("de"); + + const details = result?.details as { + mode?: string; + results?: Array<{ + title?: string; + url?: string; + snippets?: string[]; + siteName?: string; + }>; + sources?: Array<{ hostname?: string }>; + }; + expect(details.mode).toBe("llm-context"); + expect(details.results?.[0]?.url).toBe("https://example.com/ctx"); + expect(details.results?.[0]?.title).toContain("<< { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch({ + title: "unused", + url: "https://example.com", + snippets: ["unused"], + }); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + brave: { + mode: "llm-context", + }, + }, + }, + }, + }, + sandboxed: true, + }); + const result = await tool?.execute?.("call-1", { query: "test", freshness: "week" }); + + expect(result?.details).toMatchObject({ error: "unsupported_freshness" }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects date_after/date_before in Brave llm-context mode", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch({ + title: "unused", + url: "https://example.com", + snippets: ["unused"], + }); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + brave: { + mode: "llm-context", + }, + }, + }, + }, + }, + sandboxed: true, + }); + const result = await tool?.execute?.("call-1", { + query: "test", + date_after: "2025-01-01", + date_before: "2025-01-31", + }); + + expect(result?.details).toMatchObject({ error: "unsupported_date_filter" }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects ui_lang in Brave llm-context mode", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-key"); + const mockFetch = installBraveLlmContextFetch({ + title: "unused", + url: "https://example.com", + snippets: ["unused"], + }); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "brave", + brave: { + mode: "llm-context", + }, + }, + }, + }, + }, + sandboxed: true, + }); + const result = await tool?.execute?.("call-1", { + query: "test", + ui_lang: "de-DE", + }); + + expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("does not wrap Brave result urls (raw for tool chaining)", async () => { vi.stubEnv("BRAVE_API_KEY", "test-key"); const url = "https://example.com/some-page"; diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index d0b65565e415c..ba28f3f46a077 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -48,6 +48,32 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + + it("accepts brave llm-context mode config", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + provider: "brave", + providerConfig: { + mode: "llm-context", + }, + }), + ); + + expect(res.ok).toBe(true); + }); + + it("rejects invalid brave mode config values", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + provider: "brave", + providerConfig: { + mode: "invalid-mode", + }, + }), + ); + + expect(res.ok).toBe(false); + }); }); describe("web search provider auto-detection", () => { From ba2d580c4e618ef2619d6049e7e715eecd7e65b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:57:50 +0000 Subject: [PATCH 038/311] docs: note /landpr merge process --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 9ad2c7065eda3..840522224fb49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ **Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW. +- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process. - Create commits with `scripts/committer "" `; avoid manual `git add`/`git commit` so staging stays scoped. - Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). - Group related changes; avoid bundling unrelated refactors. From d5b305b250066a629d1a9d37620992814c272d36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 13:57:58 +0000 Subject: [PATCH 039/311] fix: follow up #39321 and #38445 landings --- .secrets.baseline | 4 ++-- CHANGELOG.md | 2 ++ apps/macos/Sources/OpenClaw/TalkModeRuntime.swift | 12 ++++++++---- .../LowCoverageViewSmokeTests.swift | 9 +++++++++ .../TalkModeRuntimeSpeechTests.swift | 14 ++++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift diff --git a/.secrets.baseline b/.secrets.baseline index 15df9508431d1..2964cc29bdaac 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -11481,7 +11481,7 @@ "filename": "src/agents/models-config.e2e-harness.ts", "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", "is_verified": false, - "line_number": 130 + "line_number": 131 } ], "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T05:05:36Z" + "generated_at": "2026-03-08T13:52:40Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7c0d4f82c5f..b331327d2c3b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Docs: https://docs.openclaw.ai - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. +- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. +- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. ## 2026.3.7 diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 56d44ed3ee673..77fffcd811bb8 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -70,6 +70,11 @@ actor TalkModeRuntime { private let minSpeechRMS: Double = 1e-3 private let speechBoostFactor: Double = 6.0 + static func configureRecognitionRequest(_ request: SFSpeechAudioBufferRecognitionRequest) { + request.shouldReportPartialResults = true + request.taskHint = .dictation + } + // MARK: - Lifecycle func setEnabled(_ enabled: Bool) async { @@ -176,10 +181,9 @@ actor TalkModeRuntime { return } - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - self.recognitionRequest?.taskHint = .dictation - guard let request = self.recognitionRequest else { return } + let request = SFSpeechAudioBufferRecognitionRequest() + Self.configureRecognitionRequest(request) + self.recognitionRequest = request if self.audioEngine == nil { self.audioEngine = AVAudioEngine() diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift index 0526e287b0225..4d8e5839d51d4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageViewSmokeTests.swift @@ -66,6 +66,15 @@ struct LowCoverageViewSmokeTests { try? await Task.sleep(nanoseconds: 250_000_000) } + @Test func `talk overlay presents twice and dismisses`() async { + let controller = TalkOverlayController() + controller.present() + controller.updateLevel(0.4) + controller.present() + controller.dismiss() + try? await Task.sleep(nanoseconds: 250_000_000) + } + @Test func `visual effect view hosts in NS hosting view`() { let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar)) _ = hosting.fittingSize diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift new file mode 100644 index 0000000000000..c72749daba4b9 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift @@ -0,0 +1,14 @@ +import Speech +import Testing +@testable import OpenClaw + +struct TalkModeRuntimeSpeechTests { + @Test func `speech request uses dictation defaults`() { + let request = SFSpeechAudioBufferRecognitionRequest() + + TalkModeRuntime.configureRecognitionRequest(request) + + #expect(request.shouldReportPartialResults) + #expect(request.taskHint == .dictation) + } +} From 6dadfaa18cac9921e5a915fb89eb3a407e37bc4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:10:26 +0000 Subject: [PATCH 040/311] docs: use alphabetical provider ordering --- AGENTS.md | 1 + docs/brave-search.md | 4 +- docs/perplexity.md | 31 +++--------- docs/tools/web.md | 74 ++++++++++++++--------------- src/commands/configure.wizard.ts | 2 +- src/commands/onboard-search.test.ts | 2 +- src/commands/onboard-search.ts | 20 ++++---- 7 files changed, 59 insertions(+), 75 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 840522224fb49..6aaee94b2edb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ - Docs are hosted on Mintlify (docs.openclaw.ai). - Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`). - When working with documentation, read the mintlify skill. +- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime behavior (for example auto-detection or execution order). - Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`). - Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links. - When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative). diff --git a/docs/brave-search.md b/docs/brave-search.md index d8799de96e888..3b1e55c0aae0f 100644 --- a/docs/brave-search.md +++ b/docs/brave-search.md @@ -8,13 +8,13 @@ title: "Brave Search" # Brave Search API -OpenClaw supports Brave Search as a web search provider for `web_search`. +OpenClaw supports Brave Search API as a `web_search` provider. ## Get an API key 1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/) 2. In the dashboard, choose the **Data for Search** plan and generate an API key. -3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment. +3. Store the key in config or set `BRAVE_API_KEY` in the Gateway environment. ## Config example diff --git a/docs/perplexity.md b/docs/perplexity.md index 3e8ac4a68376b..85b4a7c48c836 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -8,14 +8,14 @@ title: "Perplexity Search" # Perplexity Search API -OpenClaw uses Perplexity Search API for the `web_search` tool when `provider: "perplexity"` is set. -Perplexity Search returns structured results (title, URL, snippet) for fast research. +OpenClaw supports Perplexity Search API as a `web_search` provider. +It returns structured results with `title`, `url`, and `snippet` fields. ## Getting a Perplexity API key 1. Create a Perplexity account at 2. Generate an API key in the dashboard -3. Store the key in config (recommended) or set `PERPLEXITY_API_KEY` in the Gateway environment. +3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment. ## Config example @@ -34,29 +34,12 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese } ``` -## Switching from Brave +## Where to set the key -```json5 -{ - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { - apiKey: "pplx-...", - }, - }, - }, - }, -} -``` - -## Where to set the key (recommended) - -**Recommended:** run `openclaw configure --section web`. It stores the key in +**Via config:** run `openclaw configure --section web`. It stores the key in `~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`. -**Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway process +**Via environment:** set `PERPLEXITY_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). @@ -126,7 +109,7 @@ await web_search({ ## Notes -- Perplexity Search API returns structured web search results (title, URL, snippet) +- Perplexity Search API returns structured web search results (`title`, `url`, `snippet`) - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`) See [Web tools](/tools/web) for the full web_search configuration. diff --git a/docs/tools/web.md b/docs/tools/web.md index df1918056ab2e..4884de6bc7bb0 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,8 +1,8 @@ --- -summary: "Web search + fetch tools (Perplexity Search API, Brave, Gemini, Grok, and Kimi providers)" +summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)" read_when: - You want to enable web_search or web_fetch - - You need Perplexity or Brave Search API key setup + - You need Brave or Perplexity Search API key setup - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -11,7 +11,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web using Perplexity Search API, Brave Search API, Gemini with Google Search grounding, Grok, or Kimi. +- `web_search` — Search the web using Brave Search API, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -25,21 +25,21 @@ These are **not** browser automation. For JS-heavy sites or logins, use the (HTML → markdown/text). It does **not** execute JavaScript. - `web_fetch` is enabled by default (unless explicitly disabled). -See [Perplexity Search setup](/perplexity) and [Brave Search setup](/brave-search) for provider-specific details. +See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexity) for provider-specific details. ## Choosing a search provider -| Provider | Pros | Cons | API Key | -| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------- | -| **Perplexity Search API** | Fast, structured results; domain, language, region, and freshness filters; content extraction | — | `PERPLEXITY_API_KEY` | -| **Brave Search API** | Fast, structured results | Fewer filtering options; AI-use terms apply | `BRAVE_API_KEY` | -| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` | -| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` | -| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| Provider | Result shape | Provider-specific filters | Notes | API key | +| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------ | ----------------------------------- | +| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | +| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | +| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | +| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls | `PERPLEXITY_API_KEY` | ### Auto-detection -If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order: +The table above is alphabetical. If no `provider` is explicitly set, runtime auto-detection checks providers in this order: 1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config 2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config @@ -53,6 +53,14 @@ If no keys are found, it falls back to Brave (you'll get a missing-key error pro Use `openclaw configure --section web` to set up your API key and choose a provider. +### Brave Search + +1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/) +2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key. +3. Run `openclaw configure --section web` to store the key in config, or set `BRAVE_API_KEY` in your environment. + +Brave provides paid plans; check the Brave API portal for the current limits and pricing. + ### Perplexity Search 1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) @@ -61,23 +69,15 @@ Use `openclaw configure --section web` to set up your API key and choose a provi See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details. -### Brave Search - -1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/) -2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key. -3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment. - -Brave provides paid plans; check the Brave API portal for the current limits and pricing. - ### Where to store the key -**Via config (recommended):** run `openclaw configure --section web`. It stores the key under `tools.web.search.perplexity.apiKey` or `tools.web.search.apiKey`. +**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider. **Via environment:** set `PERPLEXITY_API_KEY` or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). ### Config examples -**Perplexity Search:** +**Brave Search:** ```json5 { @@ -85,17 +85,15 @@ Brave provides paid plans; check the Brave API portal for the current limits and web: { search: { enabled: true, - provider: "perplexity", - perplexity: { - apiKey: "pplx-...", // optional if PERPLEXITY_API_KEY is set - }, + provider: "brave", + apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret }, }, }, } ``` -**Brave Search:** +**Brave LLM Context mode:** ```json5 { @@ -105,13 +103,20 @@ Brave provides paid plans; check the Brave API portal for the current limits and enabled: true, provider: "brave", apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret + brave: { + mode: "llm-context", + }, }, }, }, } ``` -**Brave LLM Context mode:** +`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets. +In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`, +`freshness`, `date_after`, and `date_before` are rejected. + +**Perplexity Search:** ```json5 { @@ -119,10 +124,9 @@ Brave provides paid plans; check the Brave API portal for the current limits and web: { search: { enabled: true, - provider: "brave", - apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret - brave: { - mode: "llm-context", + provider: "perplexity", + perplexity: { + apiKey: "pplx-...", // optional if PERPLEXITY_API_KEY is set }, }, }, @@ -130,10 +134,6 @@ Brave provides paid plans; check the Brave API portal for the current limits and } ``` -`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets. -In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`, -`freshness`, `date_after`, and `date_before` are rejected. - ## Using Gemini (Google Search grounding) Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), @@ -186,10 +186,10 @@ Search the web using your configured provider. - `tools.web.search.enabled` must not be `false` (default: enabled) - API key for your chosen provider: - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` - - **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey` - **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` + - **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey` ### Config diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index ac31b6d5f4e4e..7a00fffbda138 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -188,7 +188,7 @@ async function promptWebToolsConfig( if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { return stored; } - return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "perplexity"; + return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "brave"; })(); note( diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 69995eef3d759..10e2df9f81bb3 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -286,6 +286,6 @@ describe("setupSearch", () => { it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); - expect(values).toEqual(["perplexity", "brave", "gemini", "grok", "kimi"]); + expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity"]); }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index f5e06a44f9695..f71a37b55da8b 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -22,18 +22,10 @@ type SearchProviderEntry = { }; export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ - { - value: "perplexity", - label: "Perplexity Search", - hint: "Structured results · domain/language/freshness filters", - envKeys: ["PERPLEXITY_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - }, { value: "brave", label: "Brave Search", - hint: "Structured results · region-specific", + hint: "Structured results · country/language/time filters", envKeys: ["BRAVE_API_KEY"], placeholder: "BSA...", signupUrl: "https://brave.com/search/api/", @@ -62,6 +54,14 @@ export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ placeholder: "sk-...", signupUrl: "https://platform.moonshot.cn/", }, + { + value: "perplexity", + label: "Perplexity Search", + hint: "Structured results · domain/country/language/time filters", + envKeys: ["PERPLEXITY_API_KEY"], + placeholder: "pplx-...", + signupUrl: "https://www.perplexity.ai/settings/api", + }, ] as const; export function hasKeyInEnv(entry: SearchProviderEntry): boolean { @@ -222,7 +222,7 @@ export async function setupSearch( if (detected) { return detected.value; } - return "perplexity"; + return "brave"; })(); type PickerValue = SearchProvider | "__skip__"; From 76e4b8277fb4dbbb8a4a893f24e2bdc37822f020 Mon Sep 17 00:00:00 2001 From: GeekCheyun Date: Sun, 8 Mar 2026 21:10:59 +0800 Subject: [PATCH 041/311] fix(issue-39839): address tool-call extra params parsing for kimi anthropic-messages --- .../pi-embedded-runner-extraparams.test.ts | 34 +++++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 9 +++++ 2 files changed, 43 insertions(+) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 6cee98487a8b9..2da38cbc8b3f4 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -803,6 +803,40 @@ describe("applyExtraParamsToAgent", () => { }); }); + it("normalizes anthropic tool_choice modes for kimi-coding endpoints", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tools: [ + { + name: "read", + description: "Read file", + input_schema: { type: "object", properties: {} }, + }, + ], + tool_choice: { type: "auto" }, + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low"); + + const model = { + api: "anthropic-messages", + provider: "kimi-coding", + id: "k2p5", + baseUrl: "https://api.kimi.com/coding/", + } as Model<"anthropic-messages">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.tool_choice).toBe("auto"); + }); + it("does not rewrite anthropic tool schema for non-kimi endpoints", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 8fca03683f1dc..98cd12ed975df 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -858,6 +858,15 @@ function normalizeKimiCodingToolChoice(toolChoice: unknown): unknown { } const choice = toolChoice as Record; + if (choice.type === "auto") { + return "auto"; + } + if (choice.type === "none") { + return "none"; + } + if (choice.type === "required") { + return "required"; + } if (choice.type === "any") { return "required"; } From 64760614aa9cb29152feadf3a476f3252b049a4a Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Tue, 3 Mar 2026 22:04:49 -0600 Subject: [PATCH 042/311] macOS: default release app builds to universal binaries --- docs/platforms/mac/release.md | 2 +- scripts/package-mac-app.sh | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 50c8222b7befd..32959dfc58671 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -40,7 +40,7 @@ BUNDLE_ID=ai.openclaw.mac \ APP_VERSION=2026.3.8 \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ -scripts/package-mac-app.sh +scripts/package-mac-dist.sh # Zip for distribution (includes resource forks for Sparkle delta support) ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.8.zip diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index c0a910c86702e..04f6925d77ba3 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -16,7 +16,14 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec APP_VERSION="${APP_VERSION:-$PKG_VERSION}" APP_BUILD="${APP_BUILD:-}" BUILD_CONFIG="${BUILD_CONFIG:-debug}" -BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}" +if [[ -n "${BUILD_ARCHS:-}" ]]; then + BUILD_ARCHS_VALUE="${BUILD_ARCHS}" +elif [[ "$BUILD_CONFIG" == "release" ]]; then + # Release packaging should be universal unless explicitly overridden. + BUILD_ARCHS_VALUE="all" +else + BUILD_ARCHS_VALUE="$(uname -m)" +fi if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then BUILD_ARCHS_VALUE="arm64 x86_64" fi From 047f4acacf9e3fe972290c0b30586e22cfb59041 Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Tue, 3 Mar 2026 22:11:23 -0600 Subject: [PATCH 043/311] Docs: clarify release build arch defaults for mac packaging --- docs/platforms/mac/release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 32959dfc58671..18c9e32995f6c 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -29,7 +29,7 @@ Notes: - `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal. - If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count. - You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value. -- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`). +- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`). - Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging. ```bash From 9ce79bba3435225bbeb50fe96aefbb25e4c59a87 Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Tue, 3 Mar 2026 22:18:27 -0600 Subject: [PATCH 044/311] Docs: mark basic mac dist example as non-notarized --- docs/platforms/mac/release.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 18c9e32995f6c..99264a2c45333 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,8 +34,10 @@ Notes: ```bash # From repo root; set release IDs so Sparkle feed is enabled. +# This command builds release artifacts without notarization. # APP_BUILD must be numeric + monotonic for Sparkle compare. # Default is auto-derived from APP_VERSION when omitted. +SKIP_NOTARIZE=1 \ BUNDLE_ID=ai.openclaw.mac \ APP_VERSION=2026.3.8 \ BUILD_CONFIG=release \ From 1a364cd066d05d281bb261cbf924ce3e61a80a27 Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Tue, 3 Mar 2026 22:48:58 -0600 Subject: [PATCH 045/311] Docs: clarify notarization handoff in mac release flow --- docs/platforms/mac/release.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 99264a2c45333..1bea6a839a070 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -44,10 +44,12 @@ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh -# Zip for distribution (includes resource forks for Sparkle delta support) +# `package-mac-dist.sh` already creates the zip + DMG. +# If you used `package-mac-app.sh` directly instead, create them manually: +# If you want notarization/stapling in this step, use the NOTARIZE command below. ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.8.zip -# Optional: also build a styled DMG for humans (drag to /Applications) +# Optional: build a styled DMG for humans (drag to /Applications) scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.8.dmg # Recommended: build + notarize/staple zip + DMG From e2c07f8a47c56cd98bb34ae2108f14844a30a465 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:13:39 +0000 Subject: [PATCH 046/311] fix: land mac universal release defaults (#33891) (thanks @cgdusek) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b331327d2c3b2..0e1770a6c8a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. + ### Fixes - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. @@ -16,6 +17,7 @@ Docs: https://docs.openclaw.ai - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. +- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. ## 2026.3.7 From 2bf53c2cb673d49dd0425626b9355194287e40a2 Mon Sep 17 00:00:00 2001 From: Varun Chopra <113368492+VarunChopra11@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:41:14 +0000 Subject: [PATCH 047/311] transcript-policy: don't preserve thinking signatures for kimi-coding (#39798) --- src/agents/transcript-policy.test.ts | 9 +++++++++ src/agents/transcript-policy.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 15c03250eda26..2e6c673f26a80 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -122,6 +122,15 @@ describe("resolveTranscriptPolicy", () => { expect(policy.preserveSignatures).toBe(false); }); + it("does not preserve signatures for kimi-coding provider (#39798)", () => { + const policy = resolveTranscriptPolicy({ + provider: "kimi-coding", + modelId: "k2p5", + modelApi: "anthropic-messages", + }); + expect(policy.preserveSignatures).toBe(false); + }); + it("enables turn-ordering and assistant-merge for strict OpenAI-compatible providers (#38962)", () => { const policy = resolveTranscriptPolicy({ provider: "vllm", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 4296f62394a81..264eb941024b9 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -123,7 +123,8 @@ export function resolveTranscriptPolicy(params: { (!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization, toolCallIdMode, repairToolUseResultPairing, - preserveSignatures: isAnthropic, + // kimi-coding uses anthropic-messages API but cannot handle re-sent thinkingSignature blobs (#39798) + preserveSignatures: isAnthropic && provider !== "kimi-coding", sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, sanitizeThinkingSignatures: false, dropThinkingBlocks, From 097c588a6be7d4b68f62e6e1a762ff45ab7d4e1f Mon Sep 17 00:00:00 2001 From: Varun Chopra <113368492+VarunChopra11@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:48:22 +0000 Subject: [PATCH 048/311] transcript-policy: use named Set for anthropic signature-excluded providers --- src/agents/transcript-policy.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 264eb941024b9..d2d5d232d0690 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -39,6 +39,8 @@ const OPENAI_MODEL_APIS = new Set([ ]); const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]); +// Providers that use anthropic-messages API but cannot handle re-sent thinkingSignature blobs (#39798) +const ANTHROPIC_API_SIGNATURE_EXCLUDED_PROVIDERS = new Set(["kimi-coding"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -123,8 +125,7 @@ export function resolveTranscriptPolicy(params: { (!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization, toolCallIdMode, repairToolUseResultPairing, - // kimi-coding uses anthropic-messages API but cannot handle re-sent thinkingSignature blobs (#39798) - preserveSignatures: isAnthropic && provider !== "kimi-coding", + preserveSignatures: isAnthropic && !ANTHROPIC_API_SIGNATURE_EXCLUDED_PROVIDERS.has(provider), sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, sanitizeThinkingSignatures: false, dropThinkingBlocks, From 6ff7e8f42ee9c8efb7f38eaf73e70a9dead4ca98 Mon Sep 17 00:00:00 2001 From: dano does design Date: Sun, 8 Mar 2026 17:58:15 +1100 Subject: [PATCH 049/311] talk: add configurable silence timeout --- CHANGELOG.md | 1 + .../ai/openclaw/app/voice/TalkModeManager.kt | 22 +++++++++++++--- .../app/voice/TalkModeConfigParsingTest.kt | 19 ++++++++++++++ apps/ios/Sources/Voice/TalkModeManager.swift | 26 +++++++++++++++++-- .../Tests/TalkModeConfigParsingTests.swift | 20 ++++++++++++++ .../Sources/OpenClaw/TalkModeRuntime.swift | 26 +++++++++++++++++-- .../TalkModeConfigParsingTests.swift | 20 ++++++++++++++ docs/gateway/configuration-reference.md | 2 ++ docs/nodes/talk.md | 2 ++ src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 2 ++ src/config/schema.labels.ts | 1 + src/config/talk.normalize.test.ts | 2 ++ src/config/talk.ts | 14 ++++++++++ src/config/types.gateway.ts | 2 ++ src/config/zod-schema.ts | 1 + src/gateway/protocol/schema/channels.ts | 1 + src/gateway/server.talk-config.test.ts | 9 ++++++- 18 files changed, 162 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1770a6c8a0a..4c3fe57a20472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. +- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. ### Fixes diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index b1fe774a80b39..31f8c20f010e2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -59,8 +59,8 @@ class TalkModeManager( private const val tag = "TalkMode" private const val defaultModelIdFallback = "eleven_v3" private const val defaultOutputFormatFallback = "pcm_24000" -private const val defaultTalkProvider = "elevenlabs" - private const val silenceWindowMs = 500L + private const val defaultTalkProvider = "elevenlabs" + private const val defaultSilenceTimeoutMs = 700L private const val listenWatchdogMs = 12_000L private const val chatFinalWaitWithSubscribeMs = 45_000L private const val chatFinalWaitWithoutSubscribeMs = 6_000L @@ -105,6 +105,14 @@ private const val defaultTalkProvider = "elevenlabs" normalizedPayload = false, ) } + + internal fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { + val timeout = talk?.get("silenceTimeoutMs").asDoubleOrNull() ?: return defaultSilenceTimeoutMs + if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { + return defaultSilenceTimeoutMs + } + return timeout.toLong() + } } private val mainHandler = Handler(Looper.getMainLooper()) @@ -134,7 +142,7 @@ private const val defaultTalkProvider = "elevenlabs" private var listeningMode = false private var silenceJob: Job? = null - private val silenceWindowMs = 700L + private var silenceWindowMs = defaultSilenceTimeoutMs private var lastTranscript: String = "" private var lastHeardAtMs: Long? = null private var lastSpokenText: String? = null @@ -1411,6 +1419,7 @@ private const val defaultTalkProvider = "elevenlabs" activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() + val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk) if (!isCanonicalMainSessionKey(mainSessionKey)) { mainSessionKey = mainKey @@ -1427,7 +1436,11 @@ private const val defaultTalkProvider = "elevenlabs" if (!modelOverrideActive) currentModelId = defaultModelId defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } - Log.d(tag, "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId") + silenceWindowMs = silenceTimeoutMs + Log.d( + tag, + "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=$silenceTimeoutMs", + ) if (interrupt != null) interruptOnSpeech = interrupt activeProviderIsElevenLabs = activeProvider == defaultTalkProvider if (!activeProviderIsElevenLabs) { @@ -1441,6 +1454,7 @@ private const val defaultTalkProvider = "elevenlabs" } configLoaded = true } catch (_: Throwable) { + silenceWindowMs = defaultSilenceTimeoutMs defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultModelId = defaultModelIdFallback if (!modelOverrideActive) currentModelId = defaultModelId diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 9e224552ade02..bb9263140d908 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -54,4 +54,23 @@ class TalkModeConfigParsingTest { assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content) assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content) } + + @Test + fun readsConfiguredSilenceTimeoutMs() { + val talk = buildJsonObject { put("silenceTimeoutMs", 1500) } + + assertEquals(1500L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + } + + @Test + fun defaultsSilenceTimeoutMsWhenMissing() { + assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(null)) + } + + @Test + fun defaultsSilenceTimeoutMsWhenInvalid() { + val talk = buildJsonObject { put("silenceTimeoutMs", 0) } + + assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + } } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 921d3f8b1825a..3b9eb531b283e 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -34,6 +34,7 @@ final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" private static let defaultTalkProvider = "elevenlabs" + private static let defaultSilenceTimeoutMs = 900 private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false @@ -97,7 +98,7 @@ final class TalkModeManager: NSObject { private var gateway: GatewayNodeSession? private var gatewayConnected = false - private let silenceWindow: TimeInterval = 0.9 + private var silenceWindow: TimeInterval = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000 private var lastAudioActivity: Date? private var noiseFloorSamples: [Double] = [] private var noiseFloor: Double? @@ -2001,6 +2002,24 @@ extension TalkModeManager { config: normalizedProviders[providerID] ?? [:]) } + static func resolvedSilenceTimeoutMs(_ talk: [String: Any]?) -> Int { + switch talk?["silenceTimeoutMs"] { + case let timeout as Int where timeout > 0: + return timeout + case let timeout as Double + where timeout > 0 && timeout.rounded(.towardZero) == timeout && timeout <= Double(Int.max): + return Int(timeout) + case let timeout as NSNumber: + let value = timeout.doubleValue + if value > 0 && value.rounded(.towardZero) == value && value <= Double(Int.max) { + return Int(value) + } + return Self.defaultSilenceTimeoutMs + default: + return Self.defaultSilenceTimeoutMs + } + } + func reloadConfig() async { guard let gateway else { return } self.pcmFormatUnavailable = false @@ -2020,6 +2039,7 @@ extension TalkModeManager { } let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeConfig = selection?.config + let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) if let aliases = activeConfig?["voiceAliases"] as? [String: Any] { @@ -2067,8 +2087,9 @@ extension TalkModeManager { if let interrupt = talk?["interruptOnSpeech"] as? Bool { self.interruptOnSpeech = interrupt } + self.silenceWindow = TimeInterval(silenceTimeoutMs) / 1000 if selection != nil { - GatewayDiagnostics.log("talk config provider=\(activeProvider)") + GatewayDiagnostics.log("talk config provider=\(activeProvider) silenceTimeoutMs=\(silenceTimeoutMs)") } } catch { self.defaultModelId = Self.defaultModelIdFallback @@ -2079,6 +2100,7 @@ extension TalkModeManager { self.gatewayTalkDefaultModelId = nil self.gatewayTalkApiKeyConfigured = false self.gatewayTalkConfigLoaded = false + self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000 } } diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index dc4a29548e07c..b6d39df5d7b0d 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -47,4 +47,24 @@ import Testing userInfo: [NSLocalizedDescriptionKey: "queue enqueue failed"]) #expect(TalkModeManager._test_isPCMFormatRejectedByAPI(error) == false) } + + @Test func readsConfiguredSilenceTimeoutMs() { + let talk: [String: Any] = [ + "silenceTimeoutMs": 1500, + ] + + #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 1500) + } + + @Test func defaultsSilenceTimeoutMsWhenMissing() { + #expect(TalkModeManager.resolvedSilenceTimeoutMs(nil) == 900) + } + + @Test func defaultsSilenceTimeoutMsWhenInvalid() { + let talk: [String: Any] = [ + "silenceTimeoutMs": 0, + ] + + #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 900) + } } diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 77fffcd811bb8..4ae5b4cc1055b 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -12,6 +12,7 @@ actor TalkModeRuntime { private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private static let defaultModelIdFallback = "eleven_v3" private static let defaultTalkProvider = "elevenlabs" + private static let defaultSilenceTimeoutMs = 700 private final class RMSMeter: @unchecked Sendable { private let lock = NSLock() @@ -66,7 +67,7 @@ actor TalkModeRuntime { private var fallbackVoiceId: String? private var lastPlaybackWasPCM: Bool = false - private let silenceWindow: TimeInterval = 0.7 + private var silenceWindow: TimeInterval = TimeInterval(TalkModeRuntime.defaultSilenceTimeoutMs) / 1000 private let minSpeechRMS: Double = 1e-3 private let speechBoostFactor: Double = 6.0 @@ -783,6 +784,7 @@ extension TalkModeRuntime { } self.defaultOutputFormat = cfg.outputFormat self.interruptOnSpeech = cfg.interruptOnSpeech + self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000 self.apiKey = cfg.apiKey let hasApiKey = (cfg.apiKey?.isEmpty == false) let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none" @@ -792,7 +794,8 @@ extension TalkModeRuntime { "talk config voiceId=\(voiceLabel, privacy: .public) " + "modelId=\(modelLabel, privacy: .public) " + "apiKey=\(hasApiKey, privacy: .public) " + - "interrupt=\(cfg.interruptOnSpeech, privacy: .public)") + "interrupt=\(cfg.interruptOnSpeech, privacy: .public) " + + "silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public)") } private struct TalkRuntimeConfig { @@ -801,6 +804,7 @@ extension TalkModeRuntime { let modelId: String? let outputFormat: String? let interruptOnSpeech: Bool + let silenceTimeoutMs: Int let apiKey: String? } @@ -880,6 +884,21 @@ extension TalkModeRuntime { normalizedPayload: false) } + static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int { + if let timeout = talk?["silenceTimeoutMs"]?.intValue, timeout > 0 { + return timeout + } + if + let timeout = talk?["silenceTimeoutMs"]?.doubleValue, + timeout > 0, + timeout.rounded(.towardZero) == timeout, + timeout <= Double(Int.max) + { + return Int(timeout) + } + return Self.defaultSilenceTimeoutMs + } + private func fetchTalkConfig() async -> TalkRuntimeConfig { let env = ProcessInfo.processInfo.environment let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -895,6 +914,7 @@ extension TalkModeRuntime { let selection = Self.selectTalkProviderConfig(talk) let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeConfig = selection?.config + let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) let ui = snap.config?["ui"]?.dictionaryValue let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" await MainActor.run { @@ -939,6 +959,7 @@ extension TalkModeRuntime { modelId: resolvedModel, outputFormat: outputFormat, interruptOnSpeech: interrupt ?? true, + silenceTimeoutMs: silenceTimeoutMs, apiKey: resolvedApiKey) } catch { let resolvedVoice = @@ -951,6 +972,7 @@ extension TalkModeRuntime { modelId: Self.defaultModelIdFallback, outputFormat: nil, interruptOnSpeech: true, + silenceTimeoutMs: Self.defaultSilenceTimeoutMs, apiKey: resolvedApiKey) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift index baf7302d3634d..89fe7f6c996df 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -32,4 +32,24 @@ struct TalkModeConfigParsingTests { #expect(selection?.config["voiceId"]?.stringValue == "voice-legacy") #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") } + + @Test func readsConfiguredSilenceTimeoutMs() { + let talk: [String: AnyCodable] = [ + "silenceTimeoutMs": AnyCodable(1500), + ] + + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500) + } + + @Test func defaultsSilenceTimeoutMsWhenMissing() { + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == 700) + } + + @Test func defaultsSilenceTimeoutMsWhenInvalid() { + let talk: [String: AnyCodable] = [ + "silenceTimeoutMs": AnyCodable(0), + ] + + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 700) + } } diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index c69d5a373b00b..4b60b48ec75c6 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1659,6 +1659,7 @@ Defaults for Talk mode (macOS/iOS/Android). modelId: "eleven_v3", outputFormat: "mp3_44100_128", apiKey: "elevenlabs_api_key", + silenceTimeoutMs: 1500, interruptOnSpeech: true, }, } @@ -1668,6 +1669,7 @@ Defaults for Talk mode (macOS/iOS/Android). - `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects. - `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured. - `voiceAliases` lets Talk directives use friendly names. +- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700` ms on macOS and Android, `900` ms on iOS). --- diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index f5d907dd7e61b..eadde1682de6a 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -56,6 +56,7 @@ Supported keys: modelId: "eleven_v3", outputFormat: "mp3_44100_128", apiKey: "elevenlabs_api_key", + silenceTimeoutMs: 1500, interruptOnSpeech: true, }, } @@ -64,6 +65,7 @@ Supported keys: Defaults: - `interruptOnSpeech`: true +- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700` ms on macOS and Android, `900` ms on iOS) - `voiceId`: falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID` (or first ElevenLabs voice when API key is available) - `modelId`: defaults to `eleven_v3` when unset - `apiKey`: falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available) diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 1f0443d36a1bc..6cb8e4899204d 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -305,6 +305,7 @@ const TARGET_KEYS = [ "talk.modelId", "talk.outputFormat", "talk.interruptOnSpeech", + "talk.silenceTimeoutMs", "meta", "env", "env.shellEnv", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index cab191a1c0489..d22cd9e4597c6 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -163,6 +163,8 @@ export const FIELD_HELP: Record = { "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. Keep enabled for conversational turn-taking.", + "talk.silenceTimeoutMs": + "Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (700 ms on macOS and Android, 900 ms on iOS).", acp: "ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.", "acp.enabled": "Global ACP feature gate. Keep disabled unless ACP runtime + policy are configured.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 40169d3ef94f4..46350c15d94b0 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -651,6 +651,7 @@ export const FIELD_LABELS: Record = { "talk.modelId": "Talk Model ID", "talk.outputFormat": "Talk Output Format", "talk.interruptOnSpeech": "Talk Interrupt on Speech", + "talk.silenceTimeoutMs": "Talk Silence Timeout (ms)", messages: "Messages", "messages.messagePrefix": "Inbound Message Prefix", "messages.responsePrefix": "Outbound Response Prefix", diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index f61bdc7e9241b..ebca72326ab0d 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -32,6 +32,7 @@ describe("talk normalization", () => { outputFormat: "pcm_44100", apiKey: "secret-key", // pragma: allowlist secret interruptOnSpeech: false, + silenceTimeoutMs: 1500, }); expect(normalized).toEqual({ @@ -51,6 +52,7 @@ describe("talk normalization", () => { outputFormat: "pcm_44100", apiKey: "secret-key", // pragma: allowlist secret interruptOnSpeech: false, + silenceTimeoutMs: 1500, }); }); diff --git a/src/config/talk.ts b/src/config/talk.ts index cd0d45adc1a12..557153d99d852 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -47,6 +47,13 @@ function normalizeTalkSecretInput(value: unknown): TalkProviderConfig["apiKey"] return coerceSecretRef(value) ?? undefined; } +function normalizeSilenceTimeoutMs(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + return undefined; + } + return value; +} + function normalizeTalkProviderConfig(value: unknown): TalkProviderConfig | undefined { if (!isPlainObject(value)) { return undefined; @@ -125,6 +132,10 @@ function normalizedLegacyTalkFields(source: Record): Partial 0) { payload.providers = normalized.providers; } diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 0adb9d98b4f47..482dd09aeb9c5 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -70,6 +70,8 @@ export type TalkConfig = { providers?: Record; /** Stop speaking when user starts talking (default: true). */ interruptOnSpeech?: boolean; + /** Milliseconds of user silence before Talk mode sends the transcript after a pause. */ + silenceTimeoutMs?: number; /** * Legacy ElevenLabs compatibility fields. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a6c2b451b9aee..731909da72df1 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -595,6 +595,7 @@ export const OpenClawSchema = z outputFormat: z.string().optional(), apiKey: SecretInputSchema.optional().register(sensitive), interruptOnSpeech: z.boolean().optional(), + silenceTimeoutMs: z.number().int().positive().optional(), }) .strict() .optional(), diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index dc85ba12a06a2..24088fcaffd32 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -42,6 +42,7 @@ export const TalkConfigResultSchema = Type.Object( outputFormat: Type.Optional(Type.String()), apiKey: Type.Optional(Type.String()), interruptOnSpeech: Type.Optional(Type.Boolean()), + silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, ), diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 42e200d8968df..1dcb29ea49662 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -56,7 +56,11 @@ async function connectOperator(ws: GatewaySocket, scopes: string[]) { }); } -async function writeTalkConfig(config: { apiKey?: string; voiceId?: string }) { +async function writeTalkConfig(config: { + apiKey?: string; + voiceId?: string; + silenceTimeoutMs?: number; +}) { const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ talk: config }); } @@ -68,6 +72,7 @@ describe("gateway talk.config", () => { talk: { voiceId: "voice-123", apiKey: "secret-key-abc", // pragma: allowlist secret + silenceTimeoutMs: 1500, }, session: { mainKey: "main-test", @@ -88,6 +93,7 @@ describe("gateway talk.config", () => { }; apiKey?: string; voiceId?: string; + silenceTimeoutMs?: number; }; }; }>(ws, "talk.config", {}); @@ -99,6 +105,7 @@ describe("gateway talk.config", () => { ); expect(res.payload?.config?.talk?.voiceId).toBe("voice-123"); expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__"); + expect(res.payload?.config?.talk?.silenceTimeoutMs).toBe(1500); }); }); From 0af3118d08f3078dec3c2c43c14488120088d275 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:29:42 +0000 Subject: [PATCH 050/311] fix: harden talk silence timeout parsing (#39607) (thanks @danodoesdesign) Co-authored-by: dano does design --- .secrets.baseline | 32 +++++++++---------- .../ai/openclaw/app/voice/TalkModeManager.kt | 4 ++- .../app/voice/TalkModeConfigParsingTest.kt | 7 ++++ apps/ios/Sources/Voice/TalkModeManager.swift | 5 ++- .../Tests/TalkModeConfigParsingTests.swift | 8 +++++ 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 2964cc29bdaac..697214ab1ecd3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -9809,49 +9809,49 @@ "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", "is_verified": false, - "line_number": 1813 + "line_number": 1815 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 1986 + "line_number": 1988 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 2042 + "line_number": 2044 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 2274 + "line_number": 2276 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2402 + "line_number": 2404 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 2655 + "line_number": 2657 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 2657 + "line_number": 2659 } ], "docs/gateway/configuration.md": [ @@ -10198,21 +10198,21 @@ "filename": "docs/tools/web.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 90 + "line_number": 129 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 179 + "line_number": 202 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 277 + "line_number": 303 } ], "docs/tts.md": [ @@ -11583,7 +11583,7 @@ "filename": "src/agents/pi-embedded-runner/model.ts", "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", "is_verified": false, - "line_number": 272 + "line_number": 267 } ], "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ @@ -11680,7 +11680,7 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 254 + "line_number": 266 } ], "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ @@ -12335,14 +12335,14 @@ "filename": "src/config/schema.help.ts", "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", "is_verified": false, - "line_number": 649 + "line_number": 651 }, { "type": "Secret Keyword", "filename": "src/config/schema.help.ts", "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", "is_verified": false, - "line_number": 680 + "line_number": 684 } ], "src/config/schema.irc.ts": [ @@ -12388,7 +12388,7 @@ "filename": "src/config/schema.labels.ts", "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", "is_verified": false, - "line_number": 324 + "line_number": 325 } ], "src/config/slack-http-config.test.ts": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T13:52:40Z" + "generated_at": "2026-03-08T14:28:30Z" } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 31f8c20f010e2..8e17037f5186c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -107,7 +107,9 @@ class TalkModeManager( } internal fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { - val timeout = talk?.get("silenceTimeoutMs").asDoubleOrNull() ?: return defaultSilenceTimeoutMs + val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return defaultSilenceTimeoutMs + if (primitive.isString) return defaultSilenceTimeoutMs + val timeout = primitive.content.toDoubleOrNull() ?: return defaultSilenceTimeoutMs if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { return defaultSilenceTimeoutMs } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index bb9263140d908..a1a06e6aac33f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -73,4 +73,11 @@ class TalkModeConfigParsingTest { assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) } + + @Test + fun defaultsSilenceTimeoutMsWhenString() { + val talk = buildJsonObject { put("silenceTimeoutMs", "1500") } + + assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + } } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 3b9eb531b283e..04172c14bfa6d 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -98,7 +98,7 @@ final class TalkModeManager: NSObject { private var gateway: GatewayNodeSession? private var gatewayConnected = false - private var silenceWindow: TimeInterval = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000 + private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000 private var lastAudioActivity: Date? private var noiseFloorSamples: [Double] = [] private var noiseFloor: Double? @@ -2010,6 +2010,9 @@ extension TalkModeManager { where timeout > 0 && timeout.rounded(.towardZero) == timeout && timeout <= Double(Int.max): return Int(timeout) case let timeout as NSNumber: + if CFGetTypeID(timeout) == CFBooleanGetTypeID() { + return Self.defaultSilenceTimeoutMs + } let value = timeout.doubleValue if value > 0 && value.rounded(.towardZero) == value && value <= Double(Int.max) { return Int(value) diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index b6d39df5d7b0d..6bf9bbef108fb 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -67,4 +67,12 @@ import Testing #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 900) } + + @Test func defaultsSilenceTimeoutMsWhenBool() { + let talk: [String: Any] = [ + "silenceTimeoutMs": true, + ] + + #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 900) + } } From 27558806b5341f2c299ac71a137b47d46305f055 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:39:39 -0500 Subject: [PATCH 051/311] docs: clarify bot review conversation ownership (#39942) * docs: clarify bot review conversations --- .github/pull_request_template.md | 7 +++++++ AGENTS.md | 1 + CONTRIBUTING.md | 14 +++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9b0e7f8dc4b17..adf5045728afd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -87,6 +87,13 @@ What you personally verified (not just CI), and how: - Edge cases checked: - What you did **not** verify: +## Review Conversations + +- [ ] I replied to or resolved every bot review conversation I addressed in this PR. +- [ ] I left unresolved only the conversations that still need reviewer or maintainer judgment. + +If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers. + ## Compatibility / Migration - Backward compatible? (`Yes/No`) diff --git a/AGENTS.md b/AGENTS.md index 6aaee94b2edb7..b70210cf8e30e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption. - GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL). - PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present). +- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers. - GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search - Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42ec9698453be..30b2ca0f0ea36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,8 +74,19 @@ Welcome to the lobster tank! 🦞 - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why +- Reply to or resolve bot review conversations you addressed before asking for review again - **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes) +## Review Conversations Are Author-Owned + +If a review bot leaves review conversations on your PR, you are expected to handle the follow-through: + +- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern +- Reply and leave it open only when you need maintainer or reviewer judgment +- Do not leave "fixed" bot review conversations for maintainers to clean up for you + +This applies to both human-authored and AI-assisted PRs. + ## Control UI Decorators The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support @@ -101,8 +112,9 @@ Please include in your PR: - [ ] Note the degree of testing (untested / lightly tested / fully tested) - [ ] Include prompts or session logs if possible (super helpful!) - [ ] Confirm you understand what the code does +- [ ] Resolve or reply to bot review conversations after you address them -AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. +AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers. ## Current Focus & Roadmap 🗺 From eba9dcc67a4c3fd706f7905f6af45615be7dad89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:49:58 +0000 Subject: [PATCH 052/311] Refactor release hardening follow-ups (#39959) * build: fail fast on stale host-env swift policy * build: sync generated host env swift policy * build: guard bundled extension root dependency gaps * refactor: centralize provider capability quirks * test: table-drive provider regression coverage * fix: block merge when prep branch has unpushed commits * refactor: simplify models config merge preservation --- .../HostEnvSecurityPolicy.generated.swift | 8 +- package.json | 2 +- scripts/pr | 25 +++++ scripts/release-check.ts | 103 ++++++++++++++++- ...ssing-provider-apikey-from-env-var.test.ts | 15 +++ src/agents/models-config.ts | 104 +++++++++++------- .../pi-embedded-runner-extraparams.test.ts | 10 +- src/agents/pi-embedded-runner/extra-params.ts | 49 +++++++-- src/agents/provider-capabilities.test.ts | 23 ++++ src/agents/provider-capabilities.ts | 41 +++++++ src/agents/transcript-policy.test.ts | 79 ++++++------- src/agents/transcript-policy.ts | 5 +- test/release-check.test.ts | 71 +++++++++++- 13 files changed, 425 insertions(+), 110 deletions(-) create mode 100644 src/agents/provider-capabilities.test.ts create mode 100644 src/agents/provider-capabilities.ts diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 3364cac36c6cd..2981a60bbf77c 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -22,7 +22,7 @@ enum HostEnvSecurityPolicy { "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE", + "SSLKEYLOGFILE" ] static let blockedOverrideKeys: Set = [ @@ -50,17 +50,17 @@ enum HostEnvSecurityPolicy { "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME", + "CURL_HOME" ] static let blockedOverridePrefixes: [String] = [ "GIT_CONFIG_", - "NPM_CONFIG_", + "NPM_CONFIG_" ] static let blockedPrefixes: [String] = [ "DYLD_", "LD_", - "BASH_FUNC_", + "BASH_FUNC_" ] } diff --git a/package.json b/package.json index ed248de2fa969..93692b174ae47 100644 --- a/package.json +++ b/package.json @@ -227,7 +227,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", + "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", diff --git a/scripts/pr b/scripts/pr index 7c04e6ad7b4e3..8e20eb7209321 100755 --- a/scripts/pr +++ b/scripts/pr @@ -229,6 +229,30 @@ checkout_prep_branch() { git checkout "$prep_branch" } +verify_prep_branch_matches_prepared_head() { + local pr="$1" + local prepared_head_sha="$2" + + require_artifact .local/prep-context.env + checkout_prep_branch "$pr" + + local prep_branch_head_sha + prep_branch_head_sha=$(git rev-parse HEAD) + if [ "$prep_branch_head_sha" = "$prepared_head_sha" ]; then + return 0 + fi + + echo "Local prep branch moved after prepare-push (expected $prepared_head_sha, got $prep_branch_head_sha)." + if git merge-base --is-ancestor "$prepared_head_sha" "$prep_branch_head_sha" 2>/dev/null; then + echo "Unpushed local commits on prep branch:" + git log --oneline "${prepared_head_sha}..${prep_branch_head_sha}" | sed 's/^/ /' || true + echo "Run scripts/pr prepare-sync-head $pr to push them before merge." + else + echo "Prep branch no longer contains the prepared head. Re-run prepare-init." + fi + exit 1 +} + resolve_head_push_url() { # shellcheck disable=SC1091 source .local/pr-meta.env @@ -1667,6 +1691,7 @@ merge_verify() { require_artifact .local/prep.env # shellcheck disable=SC1091 source .local/prep.env + verify_prep_branch_matches_prepared_head "$pr" "$PREP_HEAD_SHA" local json json=$(pr_meta_json "$pr") diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 5eb72113cc57a..095f5f2a34be9 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -8,6 +8,17 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s type PackFile = { path: string }; type PackResult = { files?: PackFile[] }; +type PackageJson = { + name?: string; + version?: string; + dependencies?: Record; + optionalDependencies?: Record; + openclaw?: { + install?: { + npmSpec?: string; + }; + }; +}; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -108,11 +119,6 @@ const appcastPath = resolve("appcast.xml"); const laneBuildMin = 1_000_000_000; const laneFloorAdoptionDateKey = 20260227; -type PackageJson = { - name?: string; - version?: string; -}; - function normalizePluginSyncVersion(version: string): string { const normalized = version.trim().replace(/^v/, ""); const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1]; @@ -122,6 +128,92 @@ function normalizePluginSyncVersion(version: string): string { return normalized.replace(/[-+].*$/, ""); } +const ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS: Record = { + googlechat: ["google-auth-library"], + matrix: ["@matrix-org/matrix-sdk-crypto-nodejs", "@vector-im/matrix-bot-sdk", "music-metadata"], + msteams: ["@microsoft/agents-hosting"], + nostr: ["nostr-tools"], + tlon: ["@tloncorp/api", "@tloncorp/tlon-skill", "@urbit/aura"], + zalouser: ["zca-js"], +}; + +export function collectBundledExtensionRootDependencyGapErrors(params: { + rootPackage: PackageJson; + extensions: Array<{ id: string; packageJson: PackageJson }>; +}): string[] { + const rootDeps = { + ...params.rootPackage.dependencies, + ...params.rootPackage.optionalDependencies, + }; + const errors: string[] = []; + + for (const extension of params.extensions) { + if (!extension.packageJson.openclaw?.install?.npmSpec) { + continue; + } + + const missing = Object.keys(extension.packageJson.dependencies ?? {}) + .filter((dep) => dep !== "openclaw" && !rootDeps[dep]) + .toSorted(); + const allowlisted = [ + ...(ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS[extension.id] ?? []), + ].toSorted(); + if (missing.join("\n") !== allowlisted.join("\n")) { + const unexpected = missing.filter((dep) => !allowlisted.includes(dep)); + const resolved = allowlisted.filter((dep) => !missing.includes(dep)); + const parts = [ + `bundled extension '${extension.id}' root dependency mirror drift`, + `missing in root package: ${missing.length > 0 ? missing.join(", ") : "(none)"}`, + ]; + if (unexpected.length > 0) { + parts.push(`new gaps: ${unexpected.join(", ")}`); + } + if (resolved.length > 0) { + parts.push(`remove stale allowlist entries: ${resolved.join(", ")}`); + } + errors.push(parts.join(" | ")); + } + } + + return errors; +} + +function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJson }> { + const extensionsDir = resolve("extensions"); + const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + return entries.flatMap((entry) => { + const packagePath = join(extensionsDir, entry.name, "package.json"); + try { + return [ + { + id: entry.name, + packageJson: JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson, + }, + ]; + } catch { + return []; + } + }); +} + +function checkBundledExtensionRootDependencyMirrors() { + const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson; + const errors = collectBundledExtensionRootDependencyGapErrors({ + rootPackage, + extensions: collectBundledExtensions(), + }); + if (errors.length > 0) { + console.error("release-check: bundled extension root dependency mirror validation failed:"); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } +} + function runPackDry(): PackResult[] { const raw = execSync("npm pack --dry-run --json --ignore-scripts", { encoding: "utf8", @@ -321,6 +413,7 @@ function main() { checkPluginVersions(); checkAppcastSparkleVersions(); checkPluginSdkExports(); + checkBundledExtensionRootDependencyMirrors(); const results = runPackDry(); const files = results.flatMap((entry) => entry.files ?? []); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 75d140867c2f8..9b6318153d32f 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -246,6 +246,21 @@ describe("models-config", () => { }); }); + it("replaces stale merged baseUrl when the provider api changes", async () => { + await withTempHome(async () => { + const parsed = await runCustomProviderMergeTest({ + seedProvider: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", // pragma: allowlist secret + api: "openai-completions", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }); + expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { await withTempHome(async () => { await writeAgentModelsJson({ diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index f891f5a65a4a2..7e96e685fe914 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -19,10 +19,14 @@ import { } from "./models-config.providers.js"; type ModelsConfig = NonNullable; +type ExistingProviderConfig = NonNullable[string] & { + apiKey?: string; + baseUrl?: string; + api?: string; +}; const DEFAULT_MODE: NonNullable = "merge"; const MODELS_JSON_WRITE_LOCKS = new Map>(); -const AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS = new Set(["openai-codex"]); function isPositiveFiniteTokenLimit(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; @@ -142,18 +146,10 @@ async function readJson(pathname: string): Promise { async function resolveProvidersForModelsJson(params: { cfg: OpenClawConfig; agentDir: string; -}): Promise<{ - providers: Record; - authoritativeImplicitBaseUrlProviders: ReadonlySet; -}> { +}): Promise> { const { cfg, agentDir } = params; const explicitProviders = cfg.models?.providers ?? {}; const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders }); - const authoritativeImplicitBaseUrlProviders = new Set( - [...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) => - Boolean(implicitProviders?.[key]), - ), - ); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders, @@ -171,52 +167,80 @@ async function resolveProvidersForModelsJson(params: { if (implicitCopilot && !providers["github-copilot"]) { providers["github-copilot"] = implicitCopilot; } - return { providers, authoritativeImplicitBaseUrlProviders }; + return providers; +} + +function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined { + if (typeof entry?.api !== "string") { + return undefined; + } + const api = entry.api.trim(); + return api || undefined; +} + +function shouldPreserveExistingApiKey(params: { + providerKey: string; + existing: ExistingProviderConfig; + secretRefManagedProviders: ReadonlySet; +}): boolean { + const { providerKey, existing, secretRefManagedProviders } = params; + return ( + !secretRefManagedProviders.has(providerKey) && + typeof existing.apiKey === "string" && + existing.apiKey.length > 0 && + !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) + ); +} + +function shouldPreserveExistingBaseUrl(params: { + providerKey: string; + existing: ExistingProviderConfig; + nextEntry: ProviderConfig; + explicitBaseUrlProviders: ReadonlySet; +}): boolean { + const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params; + if ( + explicitBaseUrlProviders.has(providerKey) || + typeof existing.baseUrl !== "string" || + existing.baseUrl.length === 0 + ) { + return false; + } + + const existingApi = resolveProviderApi(existing); + const nextApi = resolveProviderApi(nextEntry); + return !existingApi || !nextApi || existingApi === nextApi; } function mergeWithExistingProviderSecrets(params: { nextProviders: Record; - existingProviders: Record[string]>; + existingProviders: Record; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; - authoritativeImplicitBaseUrlProviders: ReadonlySet; }): Record { - const { - nextProviders, - existingProviders, - secretRefManagedProviders, - explicitBaseUrlProviders, - authoritativeImplicitBaseUrlProviders, - } = params; + const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = + params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; } for (const [key, newEntry] of Object.entries(nextProviders)) { - const existing = existingProviders[key] as - | (NonNullable[string] & { - apiKey?: string; - baseUrl?: string; - }) - | undefined; + const existing = existingProviders[key]; if (!existing) { mergedProviders[key] = newEntry; continue; } const preserved: Record = {}; - if ( - !secretRefManagedProviders.has(key) && - typeof existing.apiKey === "string" && - existing.apiKey && - !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) - ) { + if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) { preserved.apiKey = existing.apiKey; } if ( - !authoritativeImplicitBaseUrlProviders.has(key) && - !explicitBaseUrlProviders.has(key) && - typeof existing.baseUrl === "string" && - existing.baseUrl + shouldPreserveExistingBaseUrl({ + providerKey: key, + existing, + nextEntry: newEntry, + explicitBaseUrlProviders, + }) ) { preserved.baseUrl = existing.baseUrl; } @@ -231,7 +255,6 @@ async function resolveProvidersForMode(params: { providers: Record; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; - authoritativeImplicitBaseUrlProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -246,10 +269,9 @@ async function resolveProvidersForMode(params: { >; return mergeWithExistingProviderSecrets({ nextProviders: params.providers, - existingProviders, + existingProviders: existingProviders as Record, secretRefManagedProviders: params.secretRefManagedProviders, explicitBaseUrlProviders: params.explicitBaseUrlProviders, - authoritativeImplicitBaseUrlProviders: params.authoritativeImplicitBaseUrlProviders, }); } @@ -316,8 +338,7 @@ export async function ensureOpenClawModelsJson( // through the full loadConfig() pipeline which applies these. applyConfigEnvVars(cfg); - const { providers, authoritativeImplicitBaseUrlProviders } = - await resolveProvidersForModelsJson({ cfg, agentDir }); + const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; @@ -348,7 +369,6 @@ export async function ensureOpenClawModelsJson( providers: normalizedProviders, secretRefManagedProviders, explicitBaseUrlProviders, - authoritativeImplicitBaseUrlProviders, }); const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; const existingRaw = await readRawFile(targetPath); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 2da38cbc8b3f4..9755367575972 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -803,7 +803,11 @@ describe("applyExtraParamsToAgent", () => { }); }); - it("normalizes anthropic tool_choice modes for kimi-coding endpoints", () => { + it.each([ + { input: { type: "auto" }, expected: "auto" }, + { input: { type: "none" }, expected: "none" }, + { input: { type: "required" }, expected: "required" }, + ])("normalizes anthropic tool_choice %j for kimi-coding endpoints", ({ input, expected }) => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { @@ -814,7 +818,7 @@ describe("applyExtraParamsToAgent", () => { input_schema: { type: "object", properties: {} }, }, ], - tool_choice: { type: "auto" }, + tool_choice: input, }; options?.onPayload?.(payload); payloads.push(payload); @@ -834,7 +838,7 @@ describe("applyExtraParamsToAgent", () => { void agent.streamFn?.(model, context, {}); expect(payloads).toHaveLength(1); - expect(payloads[0]?.tool_choice).toBe("auto"); + expect(payloads[0]?.tool_choice).toBe(expected); }); it("does not rewrite anthropic tool schema for non-kimi endpoints", () => { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 98cd12ed975df..254d74c8f1d22 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -3,6 +3,10 @@ import type { SimpleStreamOptions } from "@mariozechner/pi-ai"; import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { + usesOpenAiFunctionAnthropicToolSchema, + usesOpenAiStringModeAnthropicToolChoice, +} from "../provider-capabilities.js"; import { log } from "./logger.js"; const OPENROUTER_APP_HEADERS: Record = { @@ -786,7 +790,7 @@ function createMoonshotThinkingWrapper( }; } -function isKimiCodingAnthropicEndpoint(model: { +function requiresAnthropicToolPayloadCompatibility(model: { api?: unknown; provider?: unknown; baseUrl?: unknown; @@ -795,7 +799,7 @@ function isKimiCodingAnthropicEndpoint(model: { return false; } - if (typeof model.provider === "string" && model.provider.trim().toLowerCase() === "kimi-coding") { + if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { return true; } @@ -814,7 +818,9 @@ function isKimiCodingAnthropicEndpoint(model: { } } -function normalizeKimiCodingToolDefinition(tool: unknown): Record | undefined { +function normalizeOpenAiFunctionAnthropicToolDefinition( + tool: unknown, +): Record | undefined { if (!tool || typeof tool !== "object" || Array.isArray(tool)) { return undefined; } @@ -852,7 +858,7 @@ function normalizeKimiCodingToolDefinition(tool: unknown): Record { const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, onPayload: (payload) => { - if (payload && typeof payload === "object" && isKimiCodingAnthropicEndpoint(model)) { + if ( + payload && + typeof payload === "object" && + requiresAnthropicToolPayloadCompatibility(model) + ) { const payloadObj = payload as Record; - if (Array.isArray(payloadObj.tools)) { + if ( + Array.isArray(payloadObj.tools) && + usesOpenAiFunctionAnthropicToolSchema( + typeof model.provider === "string" ? model.provider : undefined, + ) + ) { payloadObj.tools = payloadObj.tools - .map((tool) => normalizeKimiCodingToolDefinition(tool)) + .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) .filter((tool): tool is Record => !!tool); } - payloadObj.tool_choice = normalizeKimiCodingToolChoice(payloadObj.tool_choice); + if ( + usesOpenAiStringModeAnthropicToolChoice( + typeof model.provider === "string" ? model.provider : undefined, + ) + ) { + payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( + payloadObj.tool_choice, + ); + } } originalOnPayload?.(payload); }, @@ -1245,7 +1270,7 @@ export function applyExtraParamsToAgent( agent.streamFn = createMoonshotThinkingWrapper(agent.streamFn, moonshotThinkingType); } - agent.streamFn = createKimiCodingAnthropicToolSchemaWrapper(agent.streamFn); + agent.streamFn = createAnthropicToolPayloadCompatibilityWrapper(agent.streamFn); if (provider === "openrouter") { log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts new file mode 100644 index 0000000000000..80290f2019b3b --- /dev/null +++ b/src/agents/provider-capabilities.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveProviderCapabilities } from "./provider-capabilities.js"; + +describe("resolveProviderCapabilities", () => { + it("returns native anthropic defaults for ordinary providers", () => { + expect(resolveProviderCapabilities("anthropic")).toEqual({ + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + preserveAnthropicThinkingSignatures: true, + }); + }); + + it("normalizes kimi aliases to the same capability set", () => { + expect(resolveProviderCapabilities("kimi-coding")).toEqual( + resolveProviderCapabilities("kimi-code"), + ); + expect(resolveProviderCapabilities("kimi-code")).toEqual({ + anthropicToolSchemaMode: "openai-functions", + anthropicToolChoiceMode: "openai-string-modes", + preserveAnthropicThinkingSignatures: false, + }); + }); +}); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts new file mode 100644 index 0000000000000..1d3cc055047a9 --- /dev/null +++ b/src/agents/provider-capabilities.ts @@ -0,0 +1,41 @@ +import { normalizeProviderId } from "./model-selection.js"; + +export type ProviderCapabilities = { + anthropicToolSchemaMode: "native" | "openai-functions"; + anthropicToolChoiceMode: "native" | "openai-string-modes"; + preserveAnthropicThinkingSignatures: boolean; +}; + +const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + preserveAnthropicThinkingSignatures: true, +}; + +const PROVIDER_CAPABILITIES: Record> = { + "kimi-coding": { + anthropicToolSchemaMode: "openai-functions", + anthropicToolChoiceMode: "openai-string-modes", + preserveAnthropicThinkingSignatures: false, + }, +}; + +export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities { + const normalized = normalizeProviderId(provider ?? ""); + return { + ...DEFAULT_PROVIDER_CAPABILITIES, + ...PROVIDER_CAPABILITIES[normalized], + }; +} + +export function preservesAnthropicThinkingSignatures(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).preserveAnthropicThinkingSignatures; +} + +export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).anthropicToolSchemaMode === "openai-functions"; +} + +export function usesOpenAiStringModeAnthropicToolChoice(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).anthropicToolChoiceMode === "openai-string-modes"; +} diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 2e6c673f26a80..0737fc43b20da 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -78,57 +78,58 @@ describe("resolveTranscriptPolicy", () => { expect(policy.sanitizeMode).toBe("full"); }); - it("preserves thinking signatures for Anthropic provider (#32526)", () => { - const policy = resolveTranscriptPolicy({ + it.each([ + { + title: "Anthropic provider", provider: "anthropic", modelId: "claude-opus-4-5", - modelApi: "anthropic-messages", - }); - expect(policy.preserveSignatures).toBe(true); - }); - - it("preserves thinking signatures for Bedrock Anthropic (#32526)", () => { - const policy = resolveTranscriptPolicy({ + modelApi: "anthropic-messages" as const, + preserveSignatures: true, + }, + { + title: "Bedrock Anthropic", provider: "amazon-bedrock", modelId: "us.anthropic.claude-opus-4-6-v1", - modelApi: "bedrock-converse-stream", - }); - expect(policy.preserveSignatures).toBe(true); - }); - - it("does not preserve signatures for Google provider (#32526)", () => { - const policy = resolveTranscriptPolicy({ + modelApi: "bedrock-converse-stream" as const, + preserveSignatures: true, + }, + { + title: "Google provider", provider: "google", modelId: "gemini-2.0-flash", - modelApi: "google-generative-ai", - }); - expect(policy.preserveSignatures).toBe(false); - }); - - it("does not preserve signatures for OpenAI provider (#32526)", () => { - const policy = resolveTranscriptPolicy({ + modelApi: "google-generative-ai" as const, + preserveSignatures: false, + }, + { + title: "OpenAI provider", provider: "openai", modelId: "gpt-4o", - modelApi: "openai", - }); - expect(policy.preserveSignatures).toBe(false); - }); - - it("does not preserve signatures for Mistral provider (#32526)", () => { - const policy = resolveTranscriptPolicy({ + modelApi: "openai" as const, + preserveSignatures: false, + }, + { + title: "Mistral provider", provider: "mistral", modelId: "mistral-large-latest", - }); - expect(policy.preserveSignatures).toBe(false); - }); - - it("does not preserve signatures for kimi-coding provider (#39798)", () => { - const policy = resolveTranscriptPolicy({ + preserveSignatures: false, + }, + { + title: "kimi-coding provider", provider: "kimi-coding", modelId: "k2p5", - modelApi: "anthropic-messages", - }); - expect(policy.preserveSignatures).toBe(false); + modelApi: "anthropic-messages" as const, + preserveSignatures: false, + }, + { + title: "kimi-code alias", + provider: "kimi-code", + modelId: "k2p5", + modelApi: "anthropic-messages" as const, + preserveSignatures: false, + }, + ])("sets preserveSignatures for $title (#32526, #39798)", ({ preserveSignatures, ...input }) => { + const policy = resolveTranscriptPolicy(input); + expect(policy.preserveSignatures).toBe(preserveSignatures); }); it("enables turn-ordering and assistant-merge for strict OpenAI-compatible providers (#38962)", () => { diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index d2d5d232d0690..5acf96e89e7fa 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,5 +1,6 @@ import { normalizeProviderId } from "./model-selection.js"; import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; +import { preservesAnthropicThinkingSignatures } from "./provider-capabilities.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; export type TranscriptSanitizeMode = "full" | "images-only"; @@ -39,8 +40,6 @@ const OPENAI_MODEL_APIS = new Set([ ]); const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]); -// Providers that use anthropic-messages API but cannot handle re-sent thinkingSignature blobs (#39798) -const ANTHROPIC_API_SIGNATURE_EXCLUDED_PROVIDERS = new Set(["kimi-coding"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -125,7 +124,7 @@ export function resolveTranscriptPolicy(params: { (!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization, toolCallIdMode, repairToolUseResultPairing, - preserveSignatures: isAnthropic && !ANTHROPIC_API_SIGNATURE_EXCLUDED_PROVIDERS.has(provider), + preserveSignatures: isAnthropic && preservesAnthropicThinkingSignatures(provider), sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, sanitizeThinkingSignatures: false, dropThinkingBlocks, diff --git a/test/release-check.test.ts b/test/release-check.test.ts index b16d56fc36b6d..e366623a886aa 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { collectAppcastSparkleVersionErrors } from "../scripts/release-check.ts"; +import { + collectAppcastSparkleVersionErrors, + collectBundledExtensionRootDependencyGapErrors, +} from "../scripts/release-check.ts"; function makeItem(shortVersion: string, sparkleVersion: string): string { return `${shortVersion}${shortVersion}${sparkleVersion}`; @@ -26,3 +29,69 @@ describe("collectAppcastSparkleVersionErrors", () => { expect(collectAppcastSparkleVersionErrors(xml)).toEqual([]); }); }); + +describe("collectBundledExtensionRootDependencyGapErrors", () => { + it("allows known gaps but still flags unallowlisted ones", () => { + expect( + collectBundledExtensionRootDependencyGapErrors({ + rootPackage: { dependencies: {} }, + extensions: [ + { + id: "googlechat", + packageJson: { + dependencies: { "google-auth-library": "^1.0.0" }, + openclaw: { install: { npmSpec: "@openclaw/googlechat" } }, + }, + }, + { + id: "feishu", + packageJson: { + dependencies: { "@larksuiteoapi/node-sdk": "^1.59.0" }, + openclaw: { install: { npmSpec: "@openclaw/feishu" } }, + }, + }, + ], + }), + ).toEqual([ + "bundled extension 'feishu' root dependency mirror drift | missing in root package: @larksuiteoapi/node-sdk | new gaps: @larksuiteoapi/node-sdk", + ]); + }); + + it("flags newly introduced bundled extension dependency gaps", () => { + expect( + collectBundledExtensionRootDependencyGapErrors({ + rootPackage: { dependencies: {} }, + extensions: [ + { + id: "googlechat", + packageJson: { + dependencies: { "google-auth-library": "^1.0.0", undici: "^7.0.0" }, + openclaw: { install: { npmSpec: "@openclaw/googlechat" } }, + }, + }, + ], + }), + ).toEqual([ + "bundled extension 'googlechat' root dependency mirror drift | missing in root package: google-auth-library, undici | new gaps: undici", + ]); + }); + + it("flags stale allowlist entries once a gap is resolved", () => { + expect( + collectBundledExtensionRootDependencyGapErrors({ + rootPackage: { dependencies: { "google-auth-library": "^1.0.0" } }, + extensions: [ + { + id: "googlechat", + packageJson: { + dependencies: { "google-auth-library": "^1.0.0" }, + openclaw: { install: { npmSpec: "@openclaw/googlechat" } }, + }, + }, + ], + }), + ).toEqual([ + "bundled extension 'googlechat' root dependency mirror drift | missing in root package: (none) | remove stale allowlist entries: google-auth-library", + ]); + }); +}); From 4f482d2a2b4d57d67e7f3660c3e3b0993ce24b0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:43:55 +0000 Subject: [PATCH 053/311] refactor: share Apple talk config parsing --- apps/ios/Sources/Voice/TalkModeManager.swift | 76 ++++------------ .../Tests/TalkModeConfigParsingTests.swift | 15 ++-- .../Sources/OpenClaw/AnyCodable+Helpers.swift | 37 -------- .../Sources/OpenClaw/TalkModeRuntime.swift | 87 +----------------- .../OpenClawKit/AnyCodable+Helpers.swift | 88 +++++++++++++++++++ .../OpenClawKit/TalkConfigParsing.swift | 81 +++++++++++++++++ .../TalkConfigParsingTests.swift | 69 +++++++++++++++ 7 files changed, 267 insertions(+), 186 deletions(-) create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable+Helpers.swift create mode 100644 apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 04172c14bfa6d..f59d33dec6a36 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -1970,57 +1970,15 @@ extension TalkModeManager { return trimmed } - struct TalkProviderConfigSelection { - let provider: String - let config: [String: Any] - } - - 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] - 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.min() ?? - Self.defaultTalkProvider - return TalkProviderConfigSelection( - provider: providerID, - config: normalizedProviders[providerID] ?? [:]) - } - - static func resolvedSilenceTimeoutMs(_ talk: [String: Any]?) -> Int { - switch talk?["silenceTimeoutMs"] { - case let timeout as Int where timeout > 0: - return timeout - case let timeout as Double - where timeout > 0 && timeout.rounded(.towardZero) == timeout && timeout <= Double(Int.max): - return Int(timeout) - case let timeout as NSNumber: - if CFGetTypeID(timeout) == CFBooleanGetTypeID() { - return Self.defaultSilenceTimeoutMs - } - let value = timeout.doubleValue - if value > 0 && value.rounded(.towardZero) == value && value <= Double(Int.max) { - return Int(value) - } - return Self.defaultSilenceTimeoutMs - default: - return Self.defaultSilenceTimeoutMs - } + static func selectTalkProviderConfig(_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? { + TalkConfigParsing.selectProviderConfig( + talk, + defaultProvider: Self.defaultTalkProvider, + allowLegacyFallback: false) + } + + static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int { + TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: Self.defaultSilenceTimeoutMs) } func reloadConfig() async { @@ -2034,7 +1992,7 @@ 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] + let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any]) let selection = Self.selectTalkProviderConfig(talk) if talk != nil, selection == nil { GatewayDiagnostics.log( @@ -2043,12 +2001,12 @@ extension TalkModeManager { let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeConfig = selection?.config let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) - self.defaultVoiceId = (activeConfig?["voiceId"] as? String)? + self.defaultVoiceId = activeConfig?["voiceId"]?.stringValue? .trimmingCharacters(in: .whitespacesAndNewlines) - if let aliases = activeConfig?["voiceAliases"] as? [String: Any] { + if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue { var resolved: [String: String] = [:] for (key, value) in aliases { - guard let id = value as? String else { continue } + guard let id = value.stringValue else { continue } let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue } @@ -2061,14 +2019,14 @@ extension TalkModeManager { if !self.voiceOverrideActive { self.currentVoiceId = self.defaultVoiceId } - let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback if !self.modelOverrideActive { self.currentModelId = self.defaultModelId } - self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)? + self.defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue? .trimmingCharacters(in: .whitespacesAndNewlines) - let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) let localApiKey = Self.normalizedTalkApiKey( GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider)) @@ -2087,7 +2045,7 @@ extension TalkModeManager { self.gatewayTalkDefaultModelId = self.defaultModelId self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) self.gatewayTalkConfigLoaded = true - if let interrupt = talk?["interruptOnSpeech"] as? Bool { + if let interrupt = talk?["interruptOnSpeech"]?.boolValue { self.interruptOnSpeech = interrupt } self.silenceWindow = TimeInterval(silenceTimeoutMs) / 1000 diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index 6bf9bbef108fb..bf600385c359d 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit import Testing @testable import OpenClaw @@ -15,9 +16,10 @@ import Testing "voiceId": "voice-legacy", ] - let selection = TalkModeManager.selectTalkProviderConfig(talk) + let selection = TalkModeManager.selectTalkProviderConfig( + TalkConfigParsing.bridgeFoundationDictionary(talk)) #expect(selection?.provider == "elevenlabs") - #expect(selection?.config["voiceId"] as? String == "voice-normalized") + #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") } @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { @@ -26,7 +28,8 @@ import Testing "apiKey": "legacy-key", // pragma: allowlist secret ] - let selection = TalkModeManager.selectTalkProviderConfig(talk) + let selection = TalkModeManager.selectTalkProviderConfig( + TalkConfigParsing.bridgeFoundationDictionary(talk)) #expect(selection == nil) } @@ -53,7 +56,7 @@ import Testing "silenceTimeoutMs": 1500, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 1500) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 1500) } @Test func defaultsSilenceTimeoutMsWhenMissing() { @@ -65,7 +68,7 @@ import Testing "silenceTimeoutMs": 0, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 900) } @Test func defaultsSilenceTimeoutMsWhenBool() { @@ -73,6 +76,6 @@ import Testing "silenceTimeoutMs": true, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(talk) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 900) } } diff --git a/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift b/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift index 3cb8f54e39660..47420afb7f6ee 100644 --- a/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/AnyCodable+Helpers.swift @@ -4,40 +4,3 @@ import OpenClawKit // Prefer the OpenClawKit wrapper to keep gateway request payloads consistent. typealias AnyCodable = OpenClawKit.AnyCodable typealias InstanceIdentity = OpenClawKit.InstanceIdentity - -extension AnyCodable { - var stringValue: String? { - self.value as? String - } - - var boolValue: Bool? { - self.value as? Bool - } - - var intValue: Int? { - self.value as? Int - } - - var doubleValue: Double? { - self.value as? Double - } - - var dictionaryValue: [String: AnyCodable]? { - self.value as? [String: AnyCodable] - } - - var arrayValue: [AnyCodable]? { - self.value as? [AnyCodable] - } - - var foundationValue: Any { - switch self.value { - case let dict as [String: AnyCodable]: - dict.mapValues { $0.foundationValue } - case let array as [AnyCodable]: - array.map(\.foundationValue) - default: - self.value - } - } -} diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 4ae5b4cc1055b..1adc3cb0b7822 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -67,7 +67,7 @@ actor TalkModeRuntime { private var fallbackVoiceId: String? private var lastPlaybackWasPCM: Bool = false - private var silenceWindow: TimeInterval = TimeInterval(TalkModeRuntime.defaultSilenceTimeoutMs) / 1000 + private var silenceWindow: TimeInterval = .init(TalkModeRuntime.defaultSilenceTimeoutMs) / 1000 private let minSpeechRMS: Double = 1e-3 private let speechBoostFactor: Double = 6.0 @@ -808,95 +808,14 @@ 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 - } - - 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"] - let hasNormalizedPayload = rawProvider != nil || rawProviders != nil - if hasNormalizedPayload { - let normalizedProviders = Self.normalizedTalkProviders(rawProviders) - let providerID = - Self.normalizedTalkProviderID(rawProvider) ?? - normalizedProviders.keys.min() ?? - Self.defaultTalkProvider - return TalkProviderConfigSelection( - provider: providerID, - config: normalizedProviders[providerID] ?? [:], - normalizedPayload: true) - } - return TalkProviderConfigSelection( - provider: Self.defaultTalkProvider, - config: talk, - normalizedPayload: false) + TalkConfigParsing.selectProviderConfig(talk, defaultProvider: self.defaultTalkProvider) } static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int { - if let timeout = talk?["silenceTimeoutMs"]?.intValue, timeout > 0 { - return timeout - } - if - let timeout = talk?["silenceTimeoutMs"]?.doubleValue, - timeout > 0, - timeout.rounded(.towardZero) == timeout, - timeout <= Double(Int.max) - { - return Int(timeout) - } - return Self.defaultSilenceTimeoutMs + TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: self.defaultSilenceTimeoutMs) } private func fetchTalkConfig() async -> TalkRuntimeConfig { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable+Helpers.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable+Helpers.swift new file mode 100644 index 0000000000000..ee0d9c7876992 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/AnyCodable+Helpers.swift @@ -0,0 +1,88 @@ +import Foundation + +public extension AnyCodable { + var stringValue: String? { + self.value as? String + } + + var boolValue: Bool? { + if let value = self.value as? Bool { + return value + } + if let number = self.value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + return nil + } + + var intValue: Int? { + if let value = self.value as? Int { + return value + } + if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { + let value = number.doubleValue + if value > 0, value.rounded(.towardZero) == value, value <= Double(Int.max) { + return Int(value) + } + } + return nil + } + + var doubleValue: Double? { + if let value = self.value as? Double { + return value + } + if let value = self.value as? Int { + return Double(value) + } + if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() { + return number.doubleValue + } + return nil + } + + var dictionaryValue: [String: AnyCodable]? { + if let value = self.value as? [String: AnyCodable] { + return value + } + if let value = self.value as? [String: Any] { + return value.mapValues(AnyCodable.init) + } + if let value = self.value as? NSDictionary { + var converted: [String: AnyCodable] = [:] + for case let (key as String, raw) in value { + converted[key] = AnyCodable(raw) + } + return converted + } + return nil + } + + var arrayValue: [AnyCodable]? { + if let value = self.value as? [AnyCodable] { + return value + } + if let value = self.value as? [Any] { + return value.map(AnyCodable.init) + } + if let value = self.value as? NSArray { + return value.map(AnyCodable.init) + } + return nil + } + + var foundationValue: Any { + switch self.value { + case let dict as [String: AnyCodable]: + dict.mapValues(\.foundationValue) + case let array as [AnyCodable]: + array.map(\.foundationValue) + case let dict as [String: Any]: + dict.mapValues { AnyCodable($0).foundationValue } + case let array as [Any]: + array.map { AnyCodable($0).foundationValue } + default: + self.value + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift new file mode 100644 index 0000000000000..f25eba5aa6f67 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift @@ -0,0 +1,81 @@ +import Foundation + +public struct TalkProviderConfigSelection: Sendable { + public let provider: String + public let config: [String: AnyCodable] + public let normalizedPayload: Bool + + public init(provider: String, config: [String: AnyCodable], normalizedPayload: Bool) { + self.provider = provider + self.config = config + self.normalizedPayload = normalizedPayload + } +} + +public enum TalkConfigParsing { + public static func bridgeFoundationDictionary(_ raw: [String: Any]?) -> [String: AnyCodable]? { + raw?.mapValues(AnyCodable.init) + } + + public static func selectProviderConfig( + _ talk: [String: AnyCodable]?, + defaultProvider: String, + allowLegacyFallback: Bool = true, + ) -> TalkProviderConfigSelection? { + guard let talk else { return nil } + let rawProvider = talk["provider"]?.stringValue + let rawProviders = talk["providers"] + let hasNormalizedPayload = rawProvider != nil || rawProviders != nil + if hasNormalizedPayload { + let normalizedProviders = self.normalizedTalkProviders(rawProviders) + let providerID = + self.normalizedTalkProviderID(rawProvider) ?? + normalizedProviders.keys.min() ?? + defaultProvider + return TalkProviderConfigSelection( + provider: providerID, + config: normalizedProviders[providerID] ?? [:], + normalizedPayload: true) + } + guard allowLegacyFallback else { return nil } + return TalkProviderConfigSelection( + provider: defaultProvider, + config: talk, + normalizedPayload: false) + } + + public static func resolvedPositiveInt(_ value: AnyCodable?, fallback: Int) -> Int { + if let timeout = value?.intValue, timeout > 0 { + return timeout + } + if + let timeout = value?.doubleValue, + timeout > 0, + timeout.rounded(.towardZero) == timeout, + timeout <= Double(Int.max) + { + return Int(timeout) + } + return fallback + } + + public static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?, fallback: Int) -> Int { + self.resolvedPositiveInt(talk?["silenceTimeoutMs"], fallback: fallback) + } + + private static func normalizedTalkProviderID(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { + guard let providerMap = raw?.dictionaryValue else { return [:] } + return providerMap.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 + } + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift new file mode 100644 index 0000000000000..aa2f8081d3499 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift @@ -0,0 +1,69 @@ +import OpenClawKit +import Testing + +struct TalkConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: AnyCodable] = [ + "provider": AnyCodable("elevenlabs"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + "voiceId": AnyCodable("voice-legacy"), + ] + + let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") + #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 = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == false) + #expect(selection?.config["voiceId"]?.stringValue == "voice-legacy") + #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") + } + + @Test func canDisableLegacyFallback() { + let talk: [String: AnyCodable] = [ + "voiceId": AnyCodable("voice-legacy"), + ] + + let selection = TalkConfigParsing.selectProviderConfig( + talk, + defaultProvider: "elevenlabs", + allowLegacyFallback: false) + #expect(selection == nil) + } + + @Test func bridgesFoundationDictionary() { + let raw: [String: Any] = [ + "provider": "elevenlabs", + "providers": [ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ], + ] + + let bridged = TalkConfigParsing.bridgeFoundationDictionary(raw) + #expect(bridged?["provider"]?.stringValue == "elevenlabs") + let nested = bridged?["providers"]?.dictionaryValue?["elevenlabs"]?.dictionaryValue + #expect(nested?["voiceId"]?.stringValue == "voice-normalized") + } + + @Test func resolvesPositiveIntegerTimeout() { + #expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(1500), fallback: 700) == 1500) + #expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(0), fallback: 700) == 700) + #expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(true), fallback: 700) == 700) + #expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable("1500"), fallback: 700) == 700) + } +} From 4e2290b841d3c753d1a213e23c45f0970b7b94fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:47:29 +0000 Subject: [PATCH 054/311] refactor: add canonical talk config payload --- .../ai/openclaw/app/voice/TalkModeManager.kt | 11 ++++++ .../app/voice/TalkModeConfigParsingTest.kt | 30 ++++++++++++++++ .../OpenClawKit/TalkConfigParsing.swift | 16 +++++++++ .../TalkConfigParsingTests.swift | 22 ++++++++++++ src/config/defaults.ts | 6 ++-- src/config/talk.normalize.test.ts | 36 ++++++++++++++++++- src/config/talk.ts | 32 ++++++++++------- src/config/types.gateway.ts | 12 +++++++ src/gateway/protocol/schema/channels.ts | 9 +++++ src/gateway/server.talk-config.test.ts | 13 +++++++ 10 files changed, 171 insertions(+), 16 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 8e17037f5186c..8b57b47a2f930 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -77,8 +77,19 @@ class TalkModeManager( return trimmed.takeIf { it.isNotEmpty() } } + private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? { + val resolved = talk["resolved"].asObjectOrNull() ?: return null + val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null + return TalkProviderConfigSelection( + provider = providerId, + config = resolved["config"].asObjectOrNull() ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { if (talk == null) return null + selectResolvedTalkProviderConfig(talk)?.let { return it } val rawProvider = talk["provider"].asStringOrNull() val rawProviders = talk["providers"].asObjectOrNull() val hasNormalizedPayload = rawProvider != null || rawProviders != null diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index a1a06e6aac33f..218f7511cf056 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -13,6 +13,36 @@ import org.junit.Test class TalkModeConfigParsingTest { private val json = Json { ignoreUnknownKeys = true } + @Test + fun prefersCanonicalResolvedTalkProviderPayload() { + val talk = + json.parseToJsonElement( + """ + { + "resolved": { + "provider": "elevenlabs", + "config": { + "voiceId": "voice-resolved" + } + }, + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + } + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertNotNull(selection) + assertEquals("elevenlabs", selection?.provider) + assertTrue(selection?.normalizedPayload == true) + assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content) + } + @Test fun prefersNormalizedTalkProviderPayload() { val talk = diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift index f25eba5aa6f67..05c587b2e9df5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift @@ -23,6 +23,9 @@ public enum TalkConfigParsing { allowLegacyFallback: Bool = true, ) -> TalkProviderConfigSelection? { guard let talk else { return nil } + if let resolvedSelection = self.resolvedProviderConfig(talk) { + return resolvedSelection + } let rawProvider = talk["provider"]?.stringValue let rawProviders = talk["providers"] let hasNormalizedPayload = rawProvider != nil || rawProviders != nil @@ -68,6 +71,19 @@ public enum TalkConfigParsing { return trimmed.isEmpty ? nil : trimmed } + private static func resolvedProviderConfig( + _ talk: [String: AnyCodable] + ) -> TalkProviderConfigSelection? { + guard + let resolved = talk["resolved"]?.dictionaryValue, + let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue) + else { return nil } + return TalkProviderConfigSelection( + provider: providerID, + config: resolved["config"]?.dictionaryValue ?? [:], + normalizedPayload: true) + } + private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { guard let providerMap = raw?.dictionaryValue else { return [:] } return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift index aa2f8081d3499..5edd2ff336879 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift @@ -2,6 +2,28 @@ import OpenClawKit import Testing struct TalkConfigParsingTests { + @Test func prefersCanonicalResolvedTalkProviderPayload() { + let talk: [String: AnyCodable] = [ + "resolved": AnyCodable([ + "provider": "elevenlabs", + "config": [ + "voiceId": "voice-resolved", + ], + ]), + "provider": AnyCodable("elevenlabs"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + ] + + let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") + #expect(selection?.provider == "elevenlabs") + #expect(selection?.normalizedPayload == true) + #expect(selection?.config["voiceId"]?.stringValue == "voice-resolved") + } + @Test func prefersNormalizedTalkProviderPayload() { let talk: [String: AnyCodable] = [ "provider": AnyCodable("elevenlabs"), diff --git a/src/config/defaults.ts b/src/config/defaults.ts index b8e20f260a1ab..2febc3869ee8e 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -178,17 +178,17 @@ export function applyTalkApiKey(config: OpenClawConfig): OpenClawConfig { const talk = normalized.talk; const active = resolveActiveTalkProviderConfig(talk); - if (active.provider && active.provider !== DEFAULT_TALK_PROVIDER) { + if (active?.provider && active.provider !== DEFAULT_TALK_PROVIDER) { return normalized; } - const existingProviderApiKeyConfigured = hasConfiguredSecretInput(active.config?.apiKey); + const existingProviderApiKeyConfigured = hasConfiguredSecretInput(active?.config?.apiKey); const existingLegacyApiKeyConfigured = hasConfiguredSecretInput(talk?.apiKey); if (existingProviderApiKeyConfigured || existingLegacyApiKeyConfigured) { return normalized; } - const providerId = active.provider ?? DEFAULT_TALK_PROVIDER; + const providerId = active?.provider ?? DEFAULT_TALK_PROVIDER; const providers = { ...talk?.providers }; const providerConfig = { ...providers[providerId], apiKey: resolved }; providers[providerId] = providerConfig; diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index ebca72326ab0d..f2b1ddff1a1e0 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { createConfigIO } from "./io.js"; -import { normalizeTalkSection } from "./talk.js"; +import { buildTalkConfigResponse, normalizeTalkSection } from "./talk.js"; const envVar = (...parts: string[]) => parts.join("_"); const elevenLabsApiKeyEnv = ["ELEVENLABS_API", "KEY"].join("_"); @@ -82,6 +82,40 @@ describe("talk normalization", () => { }); }); + it("builds a canonical resolved talk payload for clients", () => { + const payload = buildTalkConfigResponse({ + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + modelId: "acme-model", + }, + }, + voiceId: "legacy-voice", + interruptOnSpeech: true, + }); + + expect(payload).toEqual({ + provider: "acme", + providers: { + acme: { + voiceId: "acme-voice", + modelId: "acme-model", + }, + }, + resolved: { + provider: "acme", + config: { + voiceId: "acme-voice", + modelId: "acme-model", + }, + }, + voiceId: "acme-voice", + modelId: "acme-model", + interruptOnSpeech: true, + }); + }); + it("preserves SecretRef apiKey values during normalization", () => { const normalized = normalizeTalkSection({ provider: "elevenlabs", diff --git a/src/config/talk.ts b/src/config/talk.ts index 557153d99d852..2d8f4b79c3dd2 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -1,7 +1,12 @@ 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 { + ResolvedTalkConfig, + TalkConfig, + TalkConfigResponse, + TalkProviderConfig, +} from "./types.gateway.js"; import type { OpenClawConfig } from "./types.js"; import { coerceSecretRef } from "./types.secrets.js"; @@ -247,25 +252,24 @@ export function normalizeTalkConfig(config: OpenClawConfig): OpenClawConfig { }; } -export function resolveActiveTalkProviderConfig(talk: TalkConfig | undefined): { - provider?: string; - config?: TalkProviderConfig; -} { +export function resolveActiveTalkProviderConfig( + talk: TalkConfig | undefined, +): ResolvedTalkConfig | undefined { const normalizedTalk = normalizeTalkSection(talk); if (!normalizedTalk) { - return {}; + return undefined; } const provider = activeProviderFromTalk(normalizedTalk); if (!provider) { - return {}; + return undefined; } return { provider, - config: normalizedTalk.providers?.[provider], + config: normalizedTalk.providers?.[provider] ?? {}, }; } -export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined { +export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | undefined { if (!isPlainObject(value)) { return undefined; } @@ -274,7 +278,7 @@ export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined return undefined; } - const payload: TalkConfig = {}; + const payload: TalkConfigResponse = {}; if (typeof normalized.interruptOnSpeech === "boolean") { payload.interruptOnSpeech = normalized.interruptOnSpeech; } @@ -288,8 +292,12 @@ export function buildTalkConfigResponse(value: unknown): TalkConfig | undefined payload.provider = normalized.provider; } - const activeProvider = activeProviderFromTalk(normalized); - const providerConfig = activeProvider ? normalized.providers?.[activeProvider] : undefined; + const resolved = resolveActiveTalkProviderConfig(normalized); + if (resolved) { + payload.resolved = resolved; + } + + const providerConfig = resolved?.config; const providerCompatibilityLegacy = legacyTalkFieldsFromProviderConfig(providerConfig); const compatibilityLegacy = Object.keys(providerCompatibilityLegacy).length > 0 diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 482dd09aeb9c5..58b061682a13f 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -63,6 +63,13 @@ export type TalkProviderConfig = { [key: string]: unknown; }; +export type ResolvedTalkConfig = { + /** Active Talk TTS provider resolved from the current config payload. */ + provider: string; + /** Provider config for the active Talk provider. */ + config: TalkProviderConfig; +}; + export type TalkConfig = { /** Active Talk TTS provider (for example "elevenlabs"). */ provider?: string; @@ -84,6 +91,11 @@ export type TalkConfig = { apiKey?: SecretInput; }; +export type TalkConfigResponse = TalkConfig & { + /** Canonical active Talk payload for clients. */ + resolved?: ResolvedTalkConfig; +}; + export type GatewayControlUiConfig = { /** If false, the Gateway will not serve the Control UI (default /). */ enabled?: boolean; diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 24088fcaffd32..cfe05819caa5f 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -27,6 +27,14 @@ const TalkProviderConfigSchema = Type.Object( { additionalProperties: true }, ); +const ResolvedTalkConfigSchema = Type.Object( + { + provider: Type.String(), + config: TalkProviderConfigSchema, + }, + { additionalProperties: false }, +); + export const TalkConfigResultSchema = Type.Object( { config: Type.Object( @@ -36,6 +44,7 @@ export const TalkConfigResultSchema = Type.Object( { provider: Type.Optional(Type.String()), providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)), + resolved: Type.Optional(ResolvedTalkConfigSchema), 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.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 1dcb29ea49662..5c7fb760ed9db 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -91,6 +91,10 @@ describe("gateway talk.config", () => { providers?: { elevenlabs?: { voiceId?: string; apiKey?: string }; }; + resolved?: { + provider?: string; + config?: { voiceId?: string; apiKey?: string }; + }; apiKey?: string; voiceId?: string; silenceTimeoutMs?: number; @@ -103,6 +107,9 @@ describe("gateway talk.config", () => { expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toBe( "__OPENCLAW_REDACTED__", ); + expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-123"); + expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toBe("__OPENCLAW_REDACTED__"); expect(res.payload?.config?.talk?.voiceId).toBe("voice-123"); expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__"); expect(res.payload?.config?.talk?.silenceTimeoutMs).toBe(1500); @@ -156,6 +163,10 @@ describe("gateway talk.config", () => { providers?: { elevenlabs?: { voiceId?: string }; }; + resolved?: { + provider?: string; + config?: { voiceId?: string }; + }; voiceId?: string; }; }; @@ -163,6 +174,8 @@ describe("gateway 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?.resolved?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.resolved?.config?.voiceId).toBe("voice-normalized"); expect(res.payload?.config?.talk?.voiceId).toBe("voice-normalized"); }); }); From b4c8950417626fb1076d1dc06f2c7fe3bf1d1e0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:52:12 +0000 Subject: [PATCH 055/311] refactor: centralize talk silence timeout defaults --- .../ai/openclaw/app/voice/TalkDefaults.kt | 5 +++ .../ai/openclaw/app/voice/TalkModeManager.kt | 14 +++--- .../app/voice/TalkModeConfigParsingTest.kt | 6 +-- apps/ios/Sources/Voice/TalkDefaults.swift | 3 ++ apps/ios/Sources/Voice/TalkModeManager.swift | 2 +- .../Tests/TalkModeConfigParsingTests.swift | 6 +-- .../macos/Sources/OpenClaw/TalkDefaults.swift | 3 ++ .../Sources/OpenClaw/TalkModeRuntime.swift | 2 +- .../TalkModeConfigParsingTests.swift | 10 ++--- docs/gateway/configuration-reference.md | 2 +- docs/nodes/talk.md | 2 +- src/config/schema.help.ts | 4 +- src/config/talk-defaults.test.ts | 43 +++++++++++++++++++ src/config/talk-defaults.ts | 11 +++++ 14 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt create mode 100644 apps/ios/Sources/Voice/TalkDefaults.swift create mode 100644 apps/macos/Sources/OpenClaw/TalkDefaults.swift create mode 100644 src/config/talk-defaults.test.ts create mode 100644 src/config/talk-defaults.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt new file mode 100644 index 0000000000000..2afe245c8e5a9 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt @@ -0,0 +1,5 @@ +package ai.openclaw.app.voice + +internal object TalkDefaults { + const val defaultSilenceTimeoutMs = 700L +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 8b57b47a2f930..e63d012eb0a11 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -60,7 +60,6 @@ class TalkModeManager( private const val defaultModelIdFallback = "eleven_v3" private const val defaultOutputFormatFallback = "pcm_24000" private const val defaultTalkProvider = "elevenlabs" - private const val defaultSilenceTimeoutMs = 700L private const val listenWatchdogMs = 12_000L private const val chatFinalWaitWithSubscribeMs = 45_000L private const val chatFinalWaitWithoutSubscribeMs = 6_000L @@ -118,11 +117,12 @@ class TalkModeManager( } internal fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { - val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return defaultSilenceTimeoutMs - if (primitive.isString) return defaultSilenceTimeoutMs - val timeout = primitive.content.toDoubleOrNull() ?: return defaultSilenceTimeoutMs + val fallback = TalkDefaults.defaultSilenceTimeoutMs + val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback + if (primitive.isString) return fallback + val timeout = primitive.content.toDoubleOrNull() ?: return fallback if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { - return defaultSilenceTimeoutMs + return fallback } return timeout.toLong() } @@ -155,7 +155,7 @@ class TalkModeManager( private var listeningMode = false private var silenceJob: Job? = null - private var silenceWindowMs = defaultSilenceTimeoutMs + private var silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs private var lastTranscript: String = "" private var lastHeardAtMs: Long? = null private var lastSpokenText: String? = null @@ -1467,7 +1467,7 @@ class TalkModeManager( } configLoaded = true } catch (_: Throwable) { - silenceWindowMs = defaultSilenceTimeoutMs + silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultModelId = defaultModelIdFallback if (!modelOverrideActive) currentModelId = defaultModelId diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 218f7511cf056..9188436a18316 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -94,20 +94,20 @@ class TalkModeConfigParsingTest { @Test fun defaultsSilenceTimeoutMsWhenMissing() { - assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(null)) + assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(null)) } @Test fun defaultsSilenceTimeoutMsWhenInvalid() { val talk = buildJsonObject { put("silenceTimeoutMs", 0) } - assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) } @Test fun defaultsSilenceTimeoutMsWhenString() { val talk = buildJsonObject { put("silenceTimeoutMs", "1500") } - assertEquals(700L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) } } diff --git a/apps/ios/Sources/Voice/TalkDefaults.swift b/apps/ios/Sources/Voice/TalkDefaults.swift new file mode 100644 index 0000000000000..be837945c5266 --- /dev/null +++ b/apps/ios/Sources/Voice/TalkDefaults.swift @@ -0,0 +1,3 @@ +enum TalkDefaults { + static let silenceTimeoutMs = 900 +} diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index f59d33dec6a36..ad972af7f4518 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -34,7 +34,7 @@ final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" private static let defaultTalkProvider = "elevenlabs" - private static let defaultSilenceTimeoutMs = 900 + private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index bf600385c359d..6fad939cc7a44 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -60,7 +60,7 @@ import Testing } @Test func defaultsSilenceTimeoutMsWhenMissing() { - #expect(TalkModeManager.resolvedSilenceTimeoutMs(nil) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs) } @Test func defaultsSilenceTimeoutMsWhenInvalid() { @@ -68,7 +68,7 @@ import Testing "silenceTimeoutMs": 0, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == TalkDefaults.silenceTimeoutMs) } @Test func defaultsSilenceTimeoutMsWhenBool() { @@ -76,6 +76,6 @@ import Testing "silenceTimeoutMs": true, ] - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 900) + #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == TalkDefaults.silenceTimeoutMs) } } diff --git a/apps/macos/Sources/OpenClaw/TalkDefaults.swift b/apps/macos/Sources/OpenClaw/TalkDefaults.swift new file mode 100644 index 0000000000000..105bac4f3907a --- /dev/null +++ b/apps/macos/Sources/OpenClaw/TalkDefaults.swift @@ -0,0 +1,3 @@ +enum TalkDefaults { + static let silenceTimeoutMs = 700 +} diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 1adc3cb0b7822..ad5d7e3a886bd 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -12,7 +12,7 @@ actor TalkModeRuntime { private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts") private static let defaultModelIdFallback = "eleven_v3" private static let defaultTalkProvider = "elevenlabs" - private static let defaultSilenceTimeoutMs = 700 + private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs private final class RMSMeter: @unchecked Sendable { private let lock = NSLock() diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift index 89fe7f6c996df..15797cebf9e1f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -33,7 +33,7 @@ struct TalkModeConfigParsingTests { #expect(selection?.config["apiKey"]?.stringValue == "legacy-key") } - @Test func readsConfiguredSilenceTimeoutMs() { + @Test func `reads configured silence timeout ms`() { let talk: [String: AnyCodable] = [ "silenceTimeoutMs": AnyCodable(1500), ] @@ -41,15 +41,15 @@ struct TalkModeConfigParsingTests { #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500) } - @Test func defaultsSilenceTimeoutMsWhenMissing() { - #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == 700) + @Test func `defaults silence timeout ms when missing`() { + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs) } - @Test func defaultsSilenceTimeoutMsWhenInvalid() { + @Test func `defaults silence timeout ms when invalid`() { let talk: [String: AnyCodable] = [ "silenceTimeoutMs": AnyCodable(0), ] - #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 700) + #expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == TalkDefaults.silenceTimeoutMs) } } diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 4b60b48ec75c6..880ccdd198bc0 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1669,7 +1669,7 @@ Defaults for Talk mode (macOS/iOS/Android). - `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects. - `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured. - `voiceAliases` lets Talk directives use friendly names. -- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700` ms on macOS and Android, `900` ms on iOS). +- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`). --- diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index eadde1682de6a..0fccaa3681c7c 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -65,7 +65,7 @@ Supported keys: Defaults: - `interruptOnSpeech`: true -- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700` ms on macOS and Android, `900` ms on iOS) +- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700 ms on macOS and Android, 900 ms on iOS`) - `voiceId`: falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID` (or first ElevenLabs voice when API key is available) - `modelId`: defaults to `eleven_v3` when unset - `apiKey`: falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available) diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d22cd9e4597c6..bab8606fdc6bd 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -4,6 +4,7 @@ import { } from "../discord/monitor/timeouts.js"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; +import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; export const FIELD_HELP: Record = { meta: "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", @@ -163,8 +164,7 @@ export const FIELD_HELP: Record = { "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. Keep enabled for conversational turn-taking.", - "talk.silenceTimeoutMs": - "Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (700 ms on macOS and Android, 900 ms on iOS).", + "talk.silenceTimeoutMs": `Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (${describeTalkSilenceTimeoutDefaults()}).`, acp: "ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.", "acp.enabled": "Global ACP feature gate. Keep disabled unless ACP runtime + policy are configured.", diff --git a/src/config/talk-defaults.test.ts b/src/config/talk-defaults.test.ts new file mode 100644 index 0000000000000..1be94ef2db478 --- /dev/null +++ b/src/config/talk-defaults.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { FIELD_HELP } from "./schema.help.js"; +import { + describeTalkSilenceTimeoutDefaults, + TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM, +} from "./talk-defaults.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +function readRepoFile(relativePath: string): string { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +describe("talk silence timeout defaults", () => { + it("keeps help text and docs aligned with the policy", () => { + const defaultsDescription = describeTalkSilenceTimeoutDefaults(); + + expect(FIELD_HELP["talk.silenceTimeoutMs"]).toContain(defaultsDescription); + expect(readRepoFile("docs/gateway/configuration-reference.md")).toContain(defaultsDescription); + expect(readRepoFile("docs/nodes/talk.md")).toContain(defaultsDescription); + }); + + it("matches the Apple and Android runtime constants", () => { + const macDefaults = readRepoFile("apps/macos/Sources/OpenClaw/TalkDefaults.swift"); + const iosDefaults = readRepoFile("apps/ios/Sources/Voice/TalkDefaults.swift"); + const androidDefaults = readRepoFile( + "apps/android/app/src/main/java/ai/openclaw/app/voice/TalkDefaults.kt", + ); + + expect(macDefaults).toContain( + `static let silenceTimeoutMs = ${TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.macos}`, + ); + expect(iosDefaults).toContain( + `static let silenceTimeoutMs = ${TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.ios}`, + ); + expect(androidDefaults).toContain( + `const val defaultSilenceTimeoutMs = ${TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.android}L`, + ); + }); +}); diff --git a/src/config/talk-defaults.ts b/src/config/talk-defaults.ts new file mode 100644 index 0000000000000..ddbd2e4f90c85 --- /dev/null +++ b/src/config/talk-defaults.ts @@ -0,0 +1,11 @@ +export const TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM = { + macos: 700, + android: 700, + ios: 900, +} as const; + +export function describeTalkSilenceTimeoutDefaults(): string { + const macos = TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.macos; + const ios = TALK_SILENCE_TIMEOUT_MS_BY_PLATFORM.ios; + return `${macos} ms on macOS and Android, ${ios} ms on iOS`; +} From e8ad80afc72ac1db50b8655314ab8972ea9283fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:53:03 +0000 Subject: [PATCH 056/311] test: cover invalid talk config inputs --- src/config/config.talk-validation.test.ts | 40 +++++++++++++++++++++++ src/config/zod-schema.talk.test.ts | 28 ++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/config/config.talk-validation.test.ts create mode 100644 src/config/zod-schema.talk.test.ts diff --git a/src/config/config.talk-validation.test.ts b/src/config/config.talk-validation.test.ts new file mode 100644 index 0000000000000..8a0c93ecd3b03 --- /dev/null +++ b/src/config/config.talk-validation.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearConfigCache, loadConfig } from "./config.js"; +import { withTempHomeConfig } from "./test-helpers.js"; + +describe("talk config validation fail-closed behavior", () => { + beforeEach(() => { + clearConfigCache(); + vi.restoreAllMocks(); + }); + + it.each([ + ["boolean", true], + ["string", "1500"], + ["float", 1500.5], + ])("rejects %s talk.silenceTimeoutMs during config load", async (_label, value) => { + await withTempHomeConfig( + { + agents: { list: [{ id: "main" }] }, + talk: { + silenceTimeoutMs: value, + }, + }, + async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + let thrown: unknown; + try { + loadConfig(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as { code?: string } | undefined)?.code).toBe("INVALID_CONFIG"); + expect((thrown as Error).message).toMatch(/silenceTimeoutMs|talk/i); + expect(consoleSpy).toHaveBeenCalled(); + }, + ); + }); +}); diff --git a/src/config/zod-schema.talk.test.ts b/src/config/zod-schema.talk.test.ts new file mode 100644 index 0000000000000..6f1f22ebc1463 --- /dev/null +++ b/src/config/zod-schema.talk.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { OpenClawSchema } from "./zod-schema.js"; + +describe("OpenClawSchema talk validation", () => { + it("accepts a positive integer talk.silenceTimeoutMs", () => { + expect(() => + OpenClawSchema.parse({ + talk: { + silenceTimeoutMs: 1500, + }, + }), + ).not.toThrow(); + }); + + it.each([ + ["boolean", true], + ["string", "1500"], + ["float", 1500.5], + ])("rejects %s talk.silenceTimeoutMs", (_label, value) => { + expect(() => + OpenClawSchema.parse({ + talk: { + silenceTimeoutMs: value, + }, + }), + ).toThrow(/silenceTimeoutMs|number|integer/i); + }); +}); From da3cccb21233e2298cc693ef0071dd5a687345c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 14:57:10 +0000 Subject: [PATCH 057/311] test: decouple ios talk parsing coverage --- .../Tests/Logic/TalkConfigParsingTests.swift | 76 +++++++++++++++++++ .../Tests/TalkModeConfigParsingTests.swift | 59 +------------- apps/ios/project.yml | 40 ++++++++++ 3 files changed, 117 insertions(+), 58 deletions(-) create mode 100644 apps/ios/Tests/Logic/TalkConfigParsingTests.swift diff --git a/apps/ios/Tests/Logic/TalkConfigParsingTests.swift b/apps/ios/Tests/Logic/TalkConfigParsingTests.swift new file mode 100644 index 0000000000000..6c99b85dea2ba --- /dev/null +++ b/apps/ios/Tests/Logic/TalkConfigParsingTests.swift @@ -0,0 +1,76 @@ +import Foundation +import OpenClawKit +import Testing + +private let iOSSilenceTimeoutMs = 900 + +@Suite struct TalkConfigParsingTests { + @Test func prefersNormalizedTalkProviderPayload() { + let talk: [String: Any] = [ + "provider": "elevenlabs", + "providers": [ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ], + "voiceId": "voice-legacy", + ] + + let selection = TalkConfigParsing.selectProviderConfig( + TalkConfigParsing.bridgeFoundationDictionary(talk), + defaultProvider: "elevenlabs", + allowLegacyFallback: false) + #expect(selection?.provider == "elevenlabs") + #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + } + + @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { + let talk: [String: Any] = [ + "voiceId": "voice-legacy", + "apiKey": "legacy-key", // pragma: allowlist secret + ] + + let selection = TalkConfigParsing.selectProviderConfig( + TalkConfigParsing.bridgeFoundationDictionary(talk), + defaultProvider: "elevenlabs", + allowLegacyFallback: false) + #expect(selection == nil) + } + + @Test func readsConfiguredSilenceTimeoutMs() { + let talk: [String: Any] = [ + "silenceTimeoutMs": 1500, + ] + + #expect( + TalkConfigParsing.resolvedSilenceTimeoutMs( + TalkConfigParsing.bridgeFoundationDictionary(talk), + fallback: iOSSilenceTimeoutMs) == 1500) + } + + @Test func defaultsSilenceTimeoutMsWhenMissing() { + #expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs) + } + + @Test func defaultsSilenceTimeoutMsWhenInvalid() { + let talk: [String: Any] = [ + "silenceTimeoutMs": 0, + ] + + #expect( + TalkConfigParsing.resolvedSilenceTimeoutMs( + TalkConfigParsing.bridgeFoundationDictionary(talk), + fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs) + } + + @Test func defaultsSilenceTimeoutMsWhenBool() { + let talk: [String: Any] = [ + "silenceTimeoutMs": true, + ] + + #expect( + TalkConfigParsing.resolvedSilenceTimeoutMs( + TalkConfigParsing.bridgeFoundationDictionary(talk), + fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs) + } +} diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index 6fad939cc7a44..f27ae08bdcf4e 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -1,38 +1,9 @@ import Foundation -import OpenClawKit import Testing @testable import OpenClaw @MainActor -@Suite struct TalkModeConfigParsingTests { - @Test func prefersNormalizedTalkProviderPayload() { - let talk: [String: Any] = [ - "provider": "elevenlabs", - "providers": [ - "elevenlabs": [ - "voiceId": "voice-normalized", - ], - ], - "voiceId": "voice-legacy", - ] - - let selection = TalkModeManager.selectTalkProviderConfig( - TalkConfigParsing.bridgeFoundationDictionary(talk)) - #expect(selection?.provider == "elevenlabs") - #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") - } - - @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { - let talk: [String: Any] = [ - "voiceId": "voice-legacy", - "apiKey": "legacy-key", // pragma: allowlist secret - ] - - let selection = TalkModeManager.selectTalkProviderConfig( - TalkConfigParsing.bridgeFoundationDictionary(talk)) - #expect(selection == nil) - } - +@Suite struct TalkModeManagerTests { @Test func detectsPCMFormatRejectionFromElevenLabsError() { let error = NSError( domain: "ElevenLabsTTS", @@ -50,32 +21,4 @@ import Testing userInfo: [NSLocalizedDescriptionKey: "queue enqueue failed"]) #expect(TalkModeManager._test_isPCMFormatRejectedByAPI(error) == false) } - - @Test func readsConfiguredSilenceTimeoutMs() { - let talk: [String: Any] = [ - "silenceTimeoutMs": 1500, - ] - - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == 1500) - } - - @Test func defaultsSilenceTimeoutMsWhenMissing() { - #expect(TalkModeManager.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs) - } - - @Test func defaultsSilenceTimeoutMsWhenInvalid() { - let talk: [String: Any] = [ - "silenceTimeoutMs": 0, - ] - - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == TalkDefaults.silenceTimeoutMs) - } - - @Test func defaultsSilenceTimeoutMsWhenBool() { - let talk: [String: Any] = [ - "silenceTimeoutMs": true, - ] - - #expect(TalkModeManager.resolvedSilenceTimeoutMs(TalkConfigParsing.bridgeFoundationDictionary(talk)) == TalkDefaults.silenceTimeoutMs) - } } diff --git a/apps/ios/project.yml b/apps/ios/project.yml index f0b6011eeaf65..3d2bc93af8044 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -25,6 +25,15 @@ schemes: test: targets: - OpenClawTests + - OpenClawLogicTests + OpenClawLogicTests: + shared: true + build: + targets: + OpenClawLogicTests: all + test: + targets: + - OpenClawLogicTests targets: OpenClaw: @@ -117,8 +126,11 @@ targets: NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing. NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always. NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake. + NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations. + NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant. NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. NSSupportsLiveActivities: true + ITSAppUsesNonExemptEncryption: false UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown @@ -259,6 +271,8 @@ targets: Release: Signing.xcconfig sources: - path: Tests + excludes: + - Logic dependencies: - target: OpenClaw - package: Swabble @@ -281,3 +295,29 @@ targets: CFBundleDisplayName: OpenClawTests CFBundleShortVersionString: "2026.3.8" CFBundleVersion: "20260308" + + OpenClawLogicTests: + type: bundle.unit-test + platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + sources: + - path: Tests/Logic + dependencies: + - package: OpenClawKit + 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.logic-tests + ENABLE_APP_INTENTS_METADATA_GENERATION: NO + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + info: + path: Tests/Info.plist + properties: + CFBundleDisplayName: OpenClawLogicTests + CFBundleShortVersionString: "2026.3.8" + CFBundleVersion: "20260308" From d9e8e8ac159bdc5d049a210396e5317a31da7486 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:59:32 -0500 Subject: [PATCH 058/311] fix: resolve live config paths in status and gateway metadata (#39952) * fix: resolve live config paths in status and gateway metadata * fix: resolve remaining runtime config path references * test: cover gateway config.set config path response --- src/commands/models/list.status-command.ts | 7 +++--- src/commands/models/list.status.test.ts | 5 ++++ src/config/logging.test.ts | 25 +++++++++++++++++++ src/config/logging.ts | 6 ++--- src/gateway/server-methods/config.ts | 11 ++++---- .../server.auth.default-token.suite.ts | 4 +-- src/gateway/server.config-patch.test.ts | 25 +++++++++++++++++++ src/gateway/server/health-state.ts | 5 ++-- 8 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 src/config/logging.test.ts diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 612dbcb664b1a..59614e3f86621 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -25,7 +25,7 @@ import { } from "../../agents/model-selection.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { withProgressTotals } from "../../cli/progress.js"; -import { CONFIG_PATH } from "../../config/config.js"; +import { createConfigIO } from "../../config/config.js"; import { resolveAgentModelFallbackValues, resolveAgentModelPrimaryValue, @@ -77,6 +77,7 @@ export async function modelsStatusCommand( if (opts.plain && opts.probe) { throw new Error("--probe cannot be used with --plain output."); } + const configPath = createConfigIO().configPath; const cfg = await loadModelsConfig({ commandName: "models status", runtime }); const agentId = resolveKnownAgentId({ cfg, rawAgentId: opts.agent }); const agentDir = agentId ? resolveAgentDir(cfg, agentId) : resolveOpenClawAgentDir(); @@ -326,7 +327,7 @@ export async function modelsStatusCommand( runtime.log( JSON.stringify( { - configPath: CONFIG_PATH, + configPath, ...(agentId ? { agentId } : {}), agentDir, defaultModel: defaultLabel, @@ -389,7 +390,7 @@ export async function modelsStatusCommand( rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel; runtime.log( - `${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, shortenHomePath(CONFIG_PATH))}`, + `${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, shortenHomePath(configPath))}`, ); runtime.log( `${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize( diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 7a792ac042d5a..6f06e63f4b82d 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -64,6 +64,9 @@ const mocks = vi.hoisted(() => { getCustomProviderApiKey: vi.fn().mockReturnValue(undefined), getShellEnvAppliedKeys: vi.fn().mockReturnValue(["OPENAI_API_KEY", "ANTHROPIC_OAUTH_TOKEN"]), shouldEnableShellEnvFallback: vi.fn().mockReturnValue(true), + createConfigIO: vi.fn().mockReturnValue({ + configPath: "/tmp/openclaw-dev/openclaw.json", + }), loadConfig: vi.fn().mockReturnValue({ agents: { defaults: { @@ -115,6 +118,7 @@ vi.mock("../../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + createConfigIO: mocks.createConfigIO, loadConfig: mocks.loadConfig, }; }); @@ -200,6 +204,7 @@ describe("modelsStatusCommand auth overview", () => { expect(mocks.resolveOpenClawAgentDir).toHaveBeenCalled(); expect(payload.defaultModel).toBe("anthropic/claude-opus-4-5"); + expect(payload.configPath).toBe("/tmp/openclaw-dev/openclaw.json"); expect(payload.auth.storePath).toBe("/tmp/openclaw-agent/auth-profiles.json"); expect(payload.auth.shellEnvFallback.enabled).toBe(true); expect(payload.auth.shellEnvFallback.appliedKeys).toContain("OPENAI_API_KEY"); diff --git a/src/config/logging.test.ts b/src/config/logging.test.ts new file mode 100644 index 0000000000000..6c55961d80df9 --- /dev/null +++ b/src/config/logging.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createConfigIO: vi.fn().mockReturnValue({ + configPath: "/tmp/openclaw-dev/openclaw.json", + }), +})); + +vi.mock("./io.js", () => ({ + createConfigIO: mocks.createConfigIO, +})); + +import { formatConfigPath, logConfigUpdated } from "./logging.js"; + +describe("config logging", () => { + it("formats the live config path when no explicit path is provided", () => { + expect(formatConfigPath()).toBe("/tmp/openclaw-dev/openclaw.json"); + }); + + it("logs the live config path when no explicit path is provided", () => { + const runtime = { log: vi.fn() }; + logConfigUpdated(runtime as never); + expect(runtime.log).toHaveBeenCalledWith("Updated /tmp/openclaw-dev/openclaw.json"); + }); +}); diff --git a/src/config/logging.ts b/src/config/logging.ts index 1dd4ee8961649..cb039c1b1d02d 100644 --- a/src/config/logging.ts +++ b/src/config/logging.ts @@ -1,18 +1,18 @@ import type { RuntimeEnv } from "../runtime.js"; import { displayPath } from "../utils.js"; -import { CONFIG_PATH } from "./paths.js"; +import { createConfigIO } from "./io.js"; type LogConfigUpdatedOptions = { path?: string; suffix?: string; }; -export function formatConfigPath(path: string = CONFIG_PATH): string { +export function formatConfigPath(path: string = createConfigIO().configPath): string { return displayPath(path); } export function logConfigUpdated(runtime: RuntimeEnv, opts: LogConfigUpdatedOptions = {}): void { - const path = formatConfigPath(opts.path ?? CONFIG_PATH); + const path = formatConfigPath(opts.path ?? createConfigIO().configPath); const suffix = opts.suffix ? ` ${opts.suffix}` : ""; runtime.log(`Updated ${path}${suffix}`); } diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 5faf83ec4d6a3..9b57a126e5f25 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,7 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { - CONFIG_PATH, + createConfigIO, loadConfig, parseConfigJson5, readConfigFileSnapshot, @@ -197,6 +197,7 @@ function buildConfigRestartSentinelPayload(params: { threadId: ReturnType["threadId"]; note: string | undefined; }): RestartSentinelPayload { + const configPath = createConfigIO().configPath; return { kind: params.kind, status: "ok", @@ -208,7 +209,7 @@ function buildConfigRestartSentinelPayload(params: { doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: params.mode, - root: CONFIG_PATH, + root: configPath, }, }; } @@ -323,7 +324,7 @@ export const configHandlers: GatewayRequestHandlers = { true, { ok: true, - path: CONFIG_PATH, + path: createConfigIO().configPath, config: redactConfigObject(parsed.config, parsed.schema.uiHints), }, undefined, @@ -440,7 +441,7 @@ export const configHandlers: GatewayRequestHandlers = { true, { ok: true, - path: CONFIG_PATH, + path: createConfigIO().configPath, config: redactConfigObject(validated.config, schemaPatch.uiHints), restart, sentinel: { @@ -500,7 +501,7 @@ export const configHandlers: GatewayRequestHandlers = { true, { ok: true, - path: CONFIG_PATH, + path: createConfigIO().configPath, config: redactConfigObject(parsed.config, parsed.schema.uiHints), restart, sentinel: { diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 98bbbbe6010d9..532ec88b46a4a 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -94,7 +94,7 @@ export function registerDefaultAuthTokenSuite(): void { }); test("connect (req) handshake returns hello-ok payload", async () => { - const { CONFIG_PATH, STATE_DIR } = await import("../config/config.js"); + const { STATE_DIR, createConfigIO } = await import("../config/config.js"); const ws = await openWs(port); const res = await connectReq(ws); @@ -106,7 +106,7 @@ export function registerDefaultAuthTokenSuite(): void { } | undefined; expect(payload?.type).toBe("hello-ok"); - expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH); + expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath); expect(payload?.snapshot?.stateDir).toBe(STATE_DIR); ws.close(); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 44daced1684a6..1f2d465b4da62 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -47,6 +47,31 @@ async function resetTempDir(name: string): Promise { } describe("gateway config methods", () => { + it("round-trips config.set and returns the live config path", async () => { + const { createConfigIO } = await import("../config/config.js"); + const current = await rpcReq<{ + raw?: unknown; + hash?: string; + config?: Record; + }>(requireWs(), "config.get", {}); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + expect(current.payload?.config).toBeTruthy(); + + const res = await rpcReq<{ + ok?: boolean; + path?: string; + config?: Record; + }>(requireWs(), "config.set", { + raw: JSON.stringify(current.payload?.config ?? {}, null, 2), + baseHash: current.payload?.hash, + }); + + expect(res.ok).toBe(true); + expect(res.payload?.path).toBe(createConfigIO().configPath); + expect(res.payload?.config).toBeTruthy(); + }); + it("returns a path-scoped config schema lookup", async () => { const res = await rpcReq<{ path: string; diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index b3a9c1f33b168..0c14d6e0ad96d 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -1,6 +1,6 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; -import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js"; +import { STATE_DIR, createConfigIO, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { getUpdateAvailable } from "../../infra/update-startup.js"; @@ -16,6 +16,7 @@ let broadcastHealthUpdate: ((snap: HealthSummary) => void) | null = null; export function buildGatewaySnapshot(): Snapshot { const cfg = loadConfig(); + const configPath = createConfigIO().configPath; const defaultAgentId = resolveDefaultAgentId(cfg); const mainKey = normalizeMainKey(cfg.session?.mainKey); const mainSessionKey = resolveMainSessionKey(cfg); @@ -32,7 +33,7 @@ export function buildGatewaySnapshot(): Snapshot { stateVersion: { presence: presenceVersion, health: healthVersion }, uptimeMs, // Surface resolved paths so UIs can display the true config location. - configPath: CONFIG_PATH, + configPath, stateDir: STATE_DIR, sessionDefaults: { defaultAgentId, From 28e46d04e57ea49d5325502da1d87a56333bd0c3 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 20:37:54 +0530 Subject: [PATCH 059/311] fix(web-search): restore OpenRouter compatibility for Perplexity (#39937) (#39937) --- CHANGELOG.md | 1 + docs/perplexity.md | 51 ++- docs/tools/web.md | 47 ++- src/agents/tools/web-search.test.ts | 83 ++++ src/agents/tools/web-search.ts | 386 +++++++++++++++--- .../tools/web-tools.enabled-defaults.test.ts | 110 ++++- src/config/config.web-search-provider.test.ts | 24 +- src/config/schema.help.ts | 6 +- src/config/zod-schema.agent-runtime.ts | 4 +- 9 files changed, 631 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3fe57a20472..b6d7ae6b64361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. +- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. ## 2026.3.7 diff --git a/docs/perplexity.md b/docs/perplexity.md index 85b4a7c48c836..9259651569f3b 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -1,8 +1,8 @@ --- -summary: "Perplexity Search API setup for web_search" +summary: "Perplexity Search API and Sonar/OpenRouter compatibility for web_search" read_when: - You want to use Perplexity Search for web search - - You need PERPLEXITY_API_KEY setup + - You need PERPLEXITY_API_KEY or OPENROUTER_API_KEY setup title: "Perplexity Search" --- @@ -11,13 +11,27 @@ title: "Perplexity Search" OpenClaw supports Perplexity Search API as a `web_search` provider. It returns structured results with `title`, `url`, and `snippet` fields. +For compatibility, OpenClaw also supports legacy Perplexity Sonar/OpenRouter setups. +If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplexity.apiKey`, or set `tools.web.search.perplexity.baseUrl` / `model`, the provider switches to the chat-completions path and returns AI-synthesized answers with citations instead of structured Search API results. + ## Getting a Perplexity API key 1. Create a Perplexity account at 2. Generate an API key in the dashboard 3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment. -## Config example +## OpenRouter compatibility + +If you were already using OpenRouter for Perplexity Sonar, keep `provider: "perplexity"` and set `OPENROUTER_API_KEY` in the Gateway environment, or store an `sk-or-...` key in `tools.web.search.perplexity.apiKey`. + +Optional legacy controls: + +- `tools.web.search.perplexity.baseUrl` +- `tools.web.search.perplexity.model` + +## Config examples + +### Native Perplexity Search API ```json5 { @@ -34,17 +48,38 @@ It returns structured results with `title`, `url`, and `snippet` fields. } ``` +### OpenRouter / Sonar compatibility + +```json5 +{ + tools: { + web: { + search: { + provider: "perplexity", + perplexity: { + apiKey: "sk-or-v1-...", + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + }, + }, + }, + }, +} +``` + ## Where to set the key **Via config:** run `openclaw configure --section web`. It stores the key in `~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`. -**Via environment:** set `PERPLEXITY_API_KEY` in the Gateway process -environment. For a gateway install, put it in `~/.openclaw/.env` (or your -service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` +in the Gateway process environment. For a gateway install, put it in +`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). ## Tool parameters +These parameters apply to the native Perplexity Search API path. + | Parameter | Description | | --------------------- | ---------------------------------------------------- | | `query` | Search query (required) | @@ -58,6 +93,9 @@ service environment). See [Env vars](/help/faq#how-does-openclaw-load-environmen | `max_tokens` | Total content budget (default: 25000, max: 1000000) | | `max_tokens_per_page` | Per-page token limit (default: 2048) | +For the legacy Sonar/OpenRouter compatibility path, only `query` and `freshness` are supported. +Search API-only filters such as `country`, `language`, `date_after`, `date_before`, `domain_filter`, `max_tokens`, and `max_tokens_per_page` return explicit errors. + **Examples:** ```javascript @@ -110,6 +148,7 @@ await web_search({ ## Notes - Perplexity Search API returns structured web search results (`title`, `url`, `snippet`) +- OpenRouter or explicit `baseUrl` / `model` switches Perplexity back to Sonar chat completions for compatibility - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`) See [Web tools](/tools/web) for the full web_search configuration. diff --git a/docs/tools/web.md b/docs/tools/web.md index 4884de6bc7bb0..94732105098b1 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -29,13 +29,13 @@ See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexit ## Choosing a search provider -| Provider | Result shape | Provider-specific filters | Notes | API key | -| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------ | ----------------------------------- | -| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | -| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | -| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | -| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | -| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls | `PERPLEXITY_API_KEY` | +| Provider | Result shape | Provider-specific filters | Notes | API key | +| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | +| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` | +| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` | +| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | +| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | +| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | ### Auto-detection @@ -44,7 +44,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut 1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config 2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config 3. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config -4. **Perplexity** — `PERPLEXITY_API_KEY` env var or `tools.web.search.perplexity.apiKey` config +4. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config 5. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). @@ -67,13 +67,15 @@ Brave provides paid plans; check the Brave API portal for the current limits and 2. Generate an API key in the dashboard 3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment. +For legacy Sonar/OpenRouter compatibility, set `OPENROUTER_API_KEY` instead, or configure `tools.web.search.perplexity.apiKey` with an `sk-or-...` key. Setting `tools.web.search.perplexity.baseUrl` or `model` also opts Perplexity back into the chat-completions compatibility path. + See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details. ### Where to store the key **Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider. -**Via environment:** set `PERPLEXITY_API_KEY` or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). +**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). ### Config examples @@ -134,6 +136,26 @@ In this mode, `country` and `language` / `search_lang` still work, but `ui_lang` } ``` +**Perplexity via OpenRouter / Sonar compatibility:** + +```json5 +{ + tools: { + web: { + search: { + enabled: true, + provider: "perplexity", + perplexity: { + apiKey: "sk-or-v1-...", // optional if OPENROUTER_API_KEY is set + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + }, + }, + }, + }, +} +``` + ## Using Gemini (Google Search grounding) Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), @@ -186,10 +208,10 @@ Search the web using your configured provider. - `tools.web.search.enabled` must not be `false` (default: enabled) - API key for your chosen provider: - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` + - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` - **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` - - **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey` ### Config @@ -211,7 +233,10 @@ Search the web using your configured provider. ### Tool parameters -All parameters work for both Brave and Perplexity unless noted. +All parameters work for Brave and for native Perplexity Search API unless noted. + +Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`. +If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors. | Parameter | Description | | --------------------- | ----------------------------------------------------- | diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index de52d6bb0e372..3c9134284a5c9 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -3,6 +3,13 @@ import { withEnv } from "../../test-utils/env.js"; import { __testing } from "./web-search.js"; const { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolvePerplexityModel, + resolvePerplexityTransport, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, + resolvePerplexityApiKey, normalizeBraveLanguageParams, normalizeFreshness, normalizeToIsoDate, @@ -21,6 +28,82 @@ const { const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_"); +describe("web_search perplexity compatibility routing", () => { + it("detects API key prefixes", () => { + expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct"); + expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter"); + expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined(); + }); + + it("prefers explicit baseUrl over key-based defaults", () => { + expect(resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123")).toBe( + "https://example.com", + ); + }); + + it("resolves OpenRouter env auth and transport", () => { + withEnv({ PERPLEXITY_API_KEY: undefined, OPENROUTER_API_KEY: "sk-or-v1-test" }, () => { + expect(resolvePerplexityApiKey(undefined)).toEqual({ + apiKey: "sk-or-v1-test", + source: "openrouter_env", + }); + expect(resolvePerplexityTransport(undefined)).toMatchObject({ + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + transport: "chat_completions", + }); + }); + }); + + it("uses native Search API for direct Perplexity when no legacy overrides exist", () => { + withEnv({ PERPLEXITY_API_KEY: "pplx-test", OPENROUTER_API_KEY: undefined }, () => { + expect(resolvePerplexityTransport(undefined)).toMatchObject({ + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + transport: "search_api", + }); + }); + }); + + it("switches direct Perplexity to chat completions when model override is configured", () => { + expect(resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe( + "perplexity/sonar-reasoning-pro", + ); + expect( + resolvePerplexityTransport({ + apiKey: "pplx-test", + model: "perplexity/sonar-reasoning-pro", + }), + ).toMatchObject({ + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-reasoning-pro", + transport: "chat_completions", + }); + }); + + it("treats unrecognized configured keys as direct Perplexity by default", () => { + expect( + resolvePerplexityTransport({ + apiKey: "enterprise-perplexity-test", + }), + ).toMatchObject({ + baseUrl: "https://api.perplexity.ai", + transport: "search_api", + }); + }); + + it("normalizes direct Perplexity models for chat completions", () => { + expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true); + expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false); + expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe( + "sonar-pro", + ); + expect( + resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"), + ).toBe("perplexity/sonar-pro"); + }); +}); + 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({ diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 30327b6dadaaa..b4ef4b8348496 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -27,7 +27,12 @@ const MAX_SEARCH_COUNT = 10; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; +const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; +const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; const DEFAULT_GROK_MODEL = "grok-4-1-fast"; @@ -144,8 +149,11 @@ function normalizeToIsoDate(value: string): string | undefined { return undefined; } -function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) { - const baseSchema = { +function createWebSearchSchema(params: { + provider: (typeof SEARCH_PROVIDERS)[number]; + perplexityTransport?: PerplexityTransport; +}) { + const querySchema = { query: Type.String({ description: "Search query string." }), count: Type.Optional( Type.Number({ @@ -154,6 +162,9 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) { maximum: MAX_SEARCH_COUNT, }), ), + } as const; + + const filterSchema = { country: Type.Optional( Type.String({ description: @@ -182,9 +193,10 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) { ), } as const; - if (provider === "brave") { + if (params.provider === "brave") { return Type.Object({ - ...baseSchema, + ...querySchema, + ...filterSchema, search_lang: Type.Optional( Type.String({ description: @@ -200,25 +212,34 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) { }); } - if (provider === "perplexity") { + if (params.provider === "perplexity") { + if (params.perplexityTransport === "chat_completions") { + return Type.Object({ + ...querySchema, + freshness: filterSchema.freshness, + }); + } return Type.Object({ - ...baseSchema, + ...querySchema, + ...filterSchema, domain_filter: Type.Optional( Type.Array(Type.String(), { description: - "Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", + "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", }), ), max_tokens: Type.Optional( Type.Number({ - description: "Total content budget across all results (default: 25000, max: 1000000).", + description: + "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", minimum: 1, maximum: 1000000, }), ), max_tokens_per_page: Type.Optional( Type.Number({ - description: "Max tokens extracted per page (default: 2048).", + description: + "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", minimum: 1, }), ), @@ -226,7 +247,10 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) { } // grok, gemini, kimi, etc. - return Type.Object(baseSchema); + return Type.Object({ + ...querySchema, + ...filterSchema, + }); } type WebSearchConfig = NonNullable["web"] extends infer Web @@ -261,9 +285,13 @@ type BraveConfig = { type PerplexityConfig = { apiKey?: string; + baseUrl?: string; + model?: string; }; -type PerplexityApiKeySource = "config" | "perplexity_env" | "none"; +type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; +type PerplexityTransport = "search_api" | "chat_completions"; +type PerplexityBaseUrlHint = "direct" | "openrouter"; type GrokConfig = { apiKey?: string; @@ -336,6 +364,15 @@ type KimiSearchResponse = { }>; }; +type PerplexitySearchResponse = { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + citations?: string[]; +}; + type PerplexitySearchApiResult = { title?: string; url?: string; @@ -459,7 +496,7 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { return { error: "missing_perplexity_api_key", message: - "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", + "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", docs: "https://docs.openclaw.ai/tools/web", }; } @@ -517,23 +554,14 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE // Auto-detect provider from available API keys (priority order) if (raw === "") { - // 1. Perplexity - const perplexityConfig = resolvePerplexityConfig(search); - const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); - if (perplexityKey) { - logVerbose( - 'web_search: no provider configured, auto-detected "perplexity" from available API keys', - ); - return "perplexity"; - } - // 2. Brave + // 1. Brave if (resolveSearchApiKey(search)) { logVerbose( 'web_search: no provider configured, auto-detected "brave" from available API keys', ); return "brave"; } - // 3. Gemini + // 2. Gemini const geminiConfig = resolveGeminiConfig(search); if (resolveGeminiApiKey(geminiConfig)) { logVerbose( @@ -541,15 +569,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "gemini"; } - // 4. Grok - const grokConfig = resolveGrokConfig(search); - if (resolveGrokApiKey(grokConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "grok" from available API keys', - ); - return "grok"; - } - // 5. Kimi + // 3. Kimi const kimiConfig = resolveKimiConfig(search); if (resolveKimiApiKey(kimiConfig)) { logVerbose( @@ -557,9 +577,26 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "kimi"; } + // 4. Perplexity + const perplexityConfig = resolvePerplexityConfig(search); + const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); + if (perplexityKey) { + logVerbose( + 'web_search: no provider configured, auto-detected "perplexity" from available API keys', + ); + return "perplexity"; + } + // 5. Grok + const grokConfig = resolveGrokConfig(search); + if (resolveGrokApiKey(grokConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "grok" from available API keys', + ); + return "grok"; + } } - return "perplexity"; + return "brave"; } function resolveBraveConfig(search?: WebSearchConfig): BraveConfig { @@ -602,6 +639,11 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; } + const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); + if (fromEnvOpenRouter) { + return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; + } + return { apiKey: undefined, source: "none" }; } @@ -609,6 +651,98 @@ function normalizeApiKey(key: unknown): string { return normalizeSecretInput(key); } +function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { + if (!apiKey) { + return undefined; + } + const normalized = apiKey.toLowerCase(); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +function resolvePerplexityBaseUrl( + perplexity?: PerplexityConfig, + apiKeySource: PerplexityApiKeySource = "none", + apiKey?: string, +): string { + const fromConfig = + perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" + ? perplexity.baseUrl.trim() + : ""; + if (fromConfig) { + return fromConfig; + } + if (apiKeySource === "perplexity_env") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (apiKeySource === "openrouter_env") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + if (apiKeySource === "config") { + const inferred = inferPerplexityBaseUrlFromApiKey(apiKey); + if (inferred === "openrouter") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + return PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; +} + +function resolvePerplexityModel(perplexity?: PerplexityConfig): string { + const fromConfig = + perplexity && "model" in perplexity && typeof perplexity.model === "string" + ? perplexity.model.trim() + : ""; + return fromConfig || DEFAULT_PERPLEXITY_MODEL; +} + +function isDirectPerplexityBaseUrl(baseUrl: string): boolean { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return false; + } + try { + return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai"; + } catch { + return false; + } +} + +function resolvePerplexityRequestModel(baseUrl: string, model: string): string { + if (!isDirectPerplexityBaseUrl(baseUrl)) { + return model; + } + return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; +} + +function resolvePerplexityTransport(perplexity?: PerplexityConfig): { + apiKey?: string; + source: PerplexityApiKeySource; + baseUrl: string; + model: string; + transport: PerplexityTransport; +} { + const auth = resolvePerplexityApiKey(perplexity); + const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); + const model = resolvePerplexityModel(perplexity); + const hasLegacyOverride = Boolean( + (perplexity?.baseUrl && perplexity.baseUrl.trim()) || + (perplexity?.model && perplexity.model.trim()), + ); + return { + ...auth, + baseUrl, + model, + transport: + hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + }; +} + function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { if (!search || typeof search !== "object") { return {}; @@ -1032,6 +1166,61 @@ async function runPerplexitySearchApi(params: { ); } +async function runPerplexitySearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; + freshness?: string; +}): Promise<{ content: string; citations: string[] }> { + const baseUrl = params.baseUrl.trim().replace(/\/$/, ""); + const endpoint = `${baseUrl}/chat/completions`; + const model = resolvePerplexityRequestModel(baseUrl, params.model); + + const body: Record = { + model, + messages: [ + { + role: "user", + content: params.query, + }, + ], + }; + + if (params.freshness) { + body.search_recency_filter = params.freshness; + } + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity"); + } + + const data = (await res.json()) as PerplexitySearchResponse; + const content = data.choices?.[0]?.message?.content ?? "No response"; + const citations = data.citations ?? []; + + return { content, citations }; + }, + ); +} + async function runGrokSearch(params: { query: string; apiKey: string; @@ -1318,6 +1507,9 @@ async function runWebSearch(params: { searchDomainFilter?: string[]; maxTokens?: number; maxTokensPerPage?: number; + perplexityBaseUrl?: string; + perplexityModel?: string; + perplexityTransport?: PerplexityTransport; grokModel?: string; grokInlineCitations?: boolean; geminiModel?: string; @@ -1327,13 +1519,15 @@ async function runWebSearch(params: { }): Promise> { const effectiveBraveMode = params.braveMode ?? "web"; const providerSpecificKey = - params.provider === "grok" - ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` - : params.provider === "gemini" - ? (params.geminiModel ?? DEFAULT_GEMINI_MODEL) - : params.provider === "kimi" - ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` - : ""; + params.provider === "perplexity" + ? `${params.perplexityTransport ?? "search_api"}:${params.perplexityBaseUrl ?? PERPLEXITY_DIRECT_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}` + : params.provider === "grok" + ? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}` + : params.provider === "gemini" + ? (params.geminiModel ?? DEFAULT_GEMINI_MODEL) + : params.provider === "kimi" + ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` + : ""; const cacheKey = normalizeCacheKey( params.provider === "brave" && effectiveBraveMode === "llm-context" ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` @@ -1347,6 +1541,34 @@ async function runWebSearch(params: { const start = Date.now(); if (params.provider === "perplexity") { + if (params.perplexityTransport === "chat_completions") { + const { content, citations } = await runPerplexitySearch({ + query: params.query, + apiKey: params.apiKey, + baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL, + model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + timeoutSeconds: params.timeoutSeconds, + freshness: params.freshness, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(content, "web_search"), + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + const results = await runPerplexitySearchApi({ query: params.query, apiKey: params.apiKey, @@ -1590,6 +1812,7 @@ export function createWebSearchTool(options?: { const provider = resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); + const perplexityTransport = resolvePerplexityTransport(perplexityConfig); const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); const kimiConfig = resolveKimiConfig(search); @@ -1598,7 +1821,9 @@ export function createWebSearchTool(options?: { const description = provider === "perplexity" - ? "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering." + ? perplexityTransport.transport === "chat_completions" + ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." + : "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering." : provider === "grok" ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." : provider === "kimi" @@ -1613,13 +1838,15 @@ export function createWebSearchTool(options?: { label: "Web Search", name: "web_search", description, - parameters: createWebSearchSchema(provider), + parameters: createWebSearchSchema({ + provider, + perplexityTransport: provider === "perplexity" ? perplexityTransport.transport : undefined, + }), execute: async (_toolCallId, args) => { - const perplexityAuth = - provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined; + const perplexityRuntime = provider === "perplexity" ? perplexityTransport : undefined; const apiKey = provider === "perplexity" - ? perplexityAuth?.apiKey + ? perplexityRuntime?.apiKey : provider === "grok" ? resolveGrokApiKey(grokConfig) : provider === "kimi" @@ -1631,23 +1858,40 @@ export function createWebSearchTool(options?: { if (!apiKey) { return jsonResult(missingSearchKeyPayload(provider)); } + + const supportsStructuredPerplexityFilters = + provider === "perplexity" && perplexityRuntime?.transport === "search_api"; const params = args as Record; const query = readStringParam(params, "query", { required: true }); const count = readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; const country = readStringParam(params, "country"); - if (country && provider !== "brave" && provider !== "perplexity") { + if ( + country && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { return jsonResult({ error: "unsupported_country", - message: `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, + message: + provider === "perplexity" + ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, docs: "https://docs.openclaw.ai/tools/web", }); } const language = readStringParam(params, "language"); - if (language && provider !== "brave" && provider !== "perplexity") { + if ( + language && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { return jsonResult({ error: "unsupported_language", - message: `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, + message: + provider === "perplexity" + ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, docs: "https://docs.openclaw.ai/tools/web", }); } @@ -1724,10 +1968,17 @@ export function createWebSearchTool(options?: { docs: "https://docs.openclaw.ai/tools/web", }); } - if ((rawDateAfter || rawDateBefore) && provider !== "brave" && provider !== "perplexity") { + if ( + (rawDateAfter || rawDateBefore) && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { return jsonResult({ error: "unsupported_date_filter", - message: `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, + message: + provider === "perplexity" + ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." + : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, docs: "https://docs.openclaw.ai/tools/web", }); } @@ -1763,10 +2014,17 @@ export function createWebSearchTool(options?: { }); } const domainFilter = readStringArrayParam(params, "domain_filter"); - if (domainFilter && domainFilter.length > 0 && provider !== "perplexity") { + if ( + domainFilter && + domainFilter.length > 0 && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { return jsonResult({ error: "unsupported_domain_filter", - message: `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, + message: + provider === "perplexity" + ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, docs: "https://docs.openclaw.ai/tools/web", }); } @@ -1793,6 +2051,18 @@ export function createWebSearchTool(options?: { const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); + if ( + provider === "perplexity" && + perplexityRuntime?.transport === "chat_completions" && + (maxTokens !== undefined || maxTokensPerPage !== undefined) + ) { + return jsonResult({ + error: "unsupported_content_budget", + message: + "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } const result = await runWebSearch({ query, @@ -1811,6 +2081,9 @@ export function createWebSearchTool(options?: { searchDomainFilter: domainFilter, maxTokens: maxTokens ?? undefined, maxTokensPerPage: maxTokensPerPage ?? undefined, + perplexityBaseUrl: perplexityRuntime?.baseUrl, + perplexityModel: perplexityRuntime?.model, + perplexityTransport: perplexityRuntime?.transport, grokModel: resolveGrokModel(grokConfig), grokInlineCitations: resolveGrokInlineCitations(grokConfig), geminiModel: resolveGeminiModel(geminiConfig), @@ -1825,6 +2098,13 @@ export function createWebSearchTool(options?: { export const __testing = { resolveSearchProvider, + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolvePerplexityModel, + resolvePerplexityTransport, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, + resolvePerplexityApiKey, normalizeBraveLanguageParams, normalizeFreshness, normalizeToIsoDate, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 383ce7b9e837f..e9da69080d2e1 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -15,7 +15,11 @@ function installMockFetch(payload: unknown) { return mockFetch; } -function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string }) { +function createPerplexitySearchTool(perplexityConfig?: { + apiKey?: string; + baseUrl?: string; + model?: string; +}) { return createWebSearchTool({ config: { tools: { @@ -109,6 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array }); } +function installPerplexityChatFetch() { + return installMockFetch({ + choices: [{ message: { content: "ok" } }], + citations: ["https://example.com"], + }); +} + function createProviderSuccessPayload( provider: "brave" | "perplexity" | "grok" | "gemini" | "kimi", ) { @@ -414,6 +425,103 @@ describe("web_search perplexity Search API", () => { }); }); +describe("web_search perplexity OpenRouter compatibility", () => { + const priorFetch = global.fetch; + + afterEach(() => { + vi.unstubAllEnvs(); + global.fetch = priorFetch; + webSearchTesting.SEARCH_CACHE.clear(); + }); + + it("routes OPENROUTER_API_KEY through chat completions", async () => { + vi.stubEnv("PERPLEXITY_API_KEY", ""); + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret + const mockFetch = installPerplexityChatFetch(); + const tool = createPerplexitySearchTool(); + const result = await tool?.execute?.("call-1", { query: "test" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); + const body = parseFirstRequestBody(mockFetch); + expect(body.model).toBe("perplexity/sonar-pro"); + expect(result?.details).toMatchObject({ + provider: "perplexity", + citations: ["https://example.com"], + content: expect.stringContaining("ok"), + }); + }); + + it("routes configured sk-or key through chat completions", async () => { + const mockFetch = installPerplexityChatFetch(); + const tool = createPerplexitySearchTool({ apiKey: "sk-or-v1-test" }); // pragma: allowlist secret + await tool?.execute?.("call-1", { query: "test" }); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); + const headers = (mockFetch.mock.calls[0]?.[1] as RequestInit | undefined)?.headers as + | Record + | undefined; + expect(headers?.Authorization).toBe("Bearer sk-or-v1-test"); + }); + + it("keeps freshness support on the compatibility path", async () => { + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret + const mockFetch = installPerplexityChatFetch(); + const tool = createPerplexitySearchTool(); + await tool?.execute?.("call-1", { query: "test", freshness: "week" }); + + expect(mockFetch).toHaveBeenCalled(); + const body = parseFirstRequestBody(mockFetch); + expect(body.search_recency_filter).toBe("week"); + }); + + it("fails loud for Search API-only filters on the compatibility path", async () => { + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret + const mockFetch = installPerplexityChatFetch(); + const tool = createPerplexitySearchTool(); + const result = await tool?.execute?.("call-1", { + query: "test", + domain_filter: ["nature.com"], + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" }); + }); + + it("hides Search API-only schema params on the compatibility path", () => { + vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret + const tool = createPerplexitySearchTool(); + const properties = (tool?.parameters as { properties?: Record } | undefined) + ?.properties; + + expect(properties?.freshness).toBeDefined(); + expect(properties?.country).toBeUndefined(); + expect(properties?.language).toBeUndefined(); + expect(properties?.date_after).toBeUndefined(); + expect(properties?.date_before).toBeUndefined(); + expect(properties?.domain_filter).toBeUndefined(); + expect(properties?.max_tokens).toBeUndefined(); + expect(properties?.max_tokens_per_page).toBeUndefined(); + }); + + it("keeps structured schema params on the native Search API path", () => { + vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test"); + const tool = createPerplexitySearchTool(); + const properties = (tool?.parameters as { properties?: Record } | undefined) + ?.properties; + + expect(properties?.country).toBeDefined(); + expect(properties?.language).toBeDefined(); + expect(properties?.freshness).toBeDefined(); + expect(properties?.date_after).toBeDefined(); + expect(properties?.date_before).toBeDefined(); + expect(properties?.domain_filter).toBeDefined(); + expect(properties?.max_tokens).toBeDefined(); + expect(properties?.max_tokens_per_page).toBeDefined(); + }); +}); + describe("web_search kimi provider", () => { const priorFetch = global.fetch; diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index ba28f3f46a077..6aca3cf0d17a8 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -17,6 +17,8 @@ describe("web search provider config", () => { provider: "perplexity", providerConfig: { apiKey: "test-key", // pragma: allowlist secret + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", }, }), ); @@ -96,8 +98,8 @@ describe("web search provider auto-detection", () => { vi.restoreAllMocks(); }); - it("falls back to perplexity when no keys available", () => { - expect(resolveSearchProvider({})).toBe("perplexity"); + it("falls back to brave when no keys available", () => { + expect(resolveSearchProvider({})).toBe("brave"); }); it("auto-detects brave when only BRAVE_API_KEY is set", () => { @@ -120,6 +122,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("perplexity"); }); + it("auto-detects perplexity when only OPENROUTER_API_KEY is set", () => { + process.env.OPENROUTER_API_KEY = "sk-or-v1-test"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("perplexity"); + }); + it("auto-detects grok when only XAI_API_KEY is set", () => { process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("grok"); @@ -135,12 +142,19 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("kimi"); }); - it("follows priority order — perplexity wins when multiple keys available", () => { - process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret + it("follows priority order — brave wins when multiple keys available", () => { process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("perplexity"); + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("gemini wins over perplexity and grok when brave unavailable", () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("gemini"); }); it("brave wins over gemini and grok when perplexity unavailable", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bab8606fdc6bd..01f8c40ff6a88 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -663,11 +663,11 @@ export const FIELD_HELP: Record = { 'Kimi base URL override (default: "https://api.moonshot.ai/v1").', "tools.web.search.kimi.model": 'Kimi model override (default: "moonshot-v1-128k").', "tools.web.search.perplexity.apiKey": - "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", + "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). Direct Perplexity keys default to the Search API; OpenRouter keys use Sonar chat completions.", "tools.web.search.perplexity.baseUrl": - "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", + "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.", "tools.web.search.perplexity.model": - 'Perplexity model override (default: "perplexity/sonar-pro").', + 'Optional Sonar/OpenRouter model override (default: "perplexity/sonar-pro"). Setting this opts Perplexity into the legacy chat-completions compatibility path.', "tools.web.search.brave.mode": 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 421f8febffcc1..3ede7218b807b 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -278,8 +278,8 @@ export const ToolsWebSearchSchema = z perplexity: z .object({ apiKey: SecretInputSchema.optional().register(sensitive), - // Legacy Sonar/OpenRouter fields — kept for backward compatibility - // so existing configs don't fail validation. Ignored at runtime. + // Legacy Sonar/OpenRouter compatibility fields. + // Setting either opts Perplexity back into the chat-completions path. baseUrl: z.string().optional(), model: z.string().optional(), }) From 67b2e81360c40862f82676660460182a24909556 Mon Sep 17 00:00:00 2001 From: darkamenosa Date: Sun, 8 Mar 2026 22:33:18 +0700 Subject: [PATCH 060/311] Zalo: fix provider lifecycle restarts (#39892) * Zalo: fix provider lifecycle restarts * Zalo: add typing indicators, smart webhook cleanup, and API type fixes * fix review * add allow list test secrect * Zalo: bound webhook cleanup during shutdown * Zalo: bound typing chat action timeout * Zalo: use plugin-safe abort helper import --- extensions/zalo/src/api.test.ts | 63 +++++ extensions/zalo/src/api.ts | 42 +++- extensions/zalo/src/channel.startup.test.ts | 100 ++++++++ extensions/zalo/src/channel.ts | 14 +- extensions/zalo/src/monitor.lifecycle.test.ts | 213 +++++++++++++++++ extensions/zalo/src/monitor.ts | 218 +++++++++++++----- src/plugin-sdk/zalo.ts | 3 + 7 files changed, 590 insertions(+), 63 deletions(-) create mode 100644 extensions/zalo/src/api.test.ts create mode 100644 extensions/zalo/src/channel.startup.test.ts create mode 100644 extensions/zalo/src/monitor.lifecycle.test.ts diff --git a/extensions/zalo/src/api.test.ts b/extensions/zalo/src/api.test.ts new file mode 100644 index 0000000000000..00198f5072edf --- /dev/null +++ b/extensions/zalo/src/api.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js"; + +describe("Zalo API request methods", () => { + it("uses POST for getWebhookInfo", async () => { + const fetcher = vi.fn( + async () => new Response(JSON.stringify({ ok: true, result: {} })), + ); + + await getWebhookInfo("test-token", fetcher); + + expect(fetcher).toHaveBeenCalledTimes(1); + const [, init] = fetcher.mock.calls[0] ?? []; + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ "Content-Type": "application/json" }); + }); + + it("keeps POST for deleteWebhook", async () => { + const fetcher = vi.fn( + async () => new Response(JSON.stringify({ ok: true, result: {} })), + ); + + await deleteWebhook("test-token", fetcher); + + expect(fetcher).toHaveBeenCalledTimes(1); + const [, init] = fetcher.mock.calls[0] ?? []; + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ "Content-Type": "application/json" }); + }); + + it("aborts sendChatAction when the typing timeout elapses", async () => { + vi.useFakeTimers(); + try { + const fetcher = vi.fn( + (_, init) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }), + ); + + const promise = sendChatAction( + "test-token", + { + chat_id: "chat-123", + action: "typing", + }, + fetcher, + 25, + ); + const rejected = expect(promise).rejects.toThrow("aborted"); + + await vi.advanceTimersByTimeAsync(25); + + await rejected; + const [, init] = fetcher.mock.calls[0] ?? []; + expect(init?.signal?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extensions/zalo/src/api.ts b/extensions/zalo/src/api.ts index ad11d5044d51b..9bef1ce680eb7 100644 --- a/extensions/zalo/src/api.ts +++ b/extensions/zalo/src/api.ts @@ -58,11 +58,22 @@ export type ZaloSendPhotoParams = { caption?: string; }; +export type ZaloSendChatActionParams = { + chat_id: string; + action: "typing" | "upload_photo"; +}; + export type ZaloSetWebhookParams = { url: string; secret_token: string; }; +export type ZaloWebhookInfo = { + url?: string; + updated_at?: number; + has_custom_certificate?: boolean; +}; + export type ZaloGetUpdatesParams = { /** Timeout in seconds (passed as string to API) */ timeout?: number; @@ -161,6 +172,21 @@ export async function sendPhoto( return callZaloApi("sendPhoto", token, params, { fetch: fetcher }); } +/** + * Send a temporary chat action such as typing. + */ +export async function sendChatAction( + token: string, + params: ZaloSendChatActionParams, + fetcher?: ZaloFetch, + timeoutMs?: number, +): Promise> { + return callZaloApi("sendChatAction", token, params, { + timeoutMs, + fetch: fetcher, + }); +} + /** * Get updates using long polling (dev/testing only) * Note: Zalo returns a single update per call, not an array like Telegram @@ -183,8 +209,8 @@ export async function setWebhook( token: string, params: ZaloSetWebhookParams, fetcher?: ZaloFetch, -): Promise> { - return callZaloApi("setWebhook", token, params, { fetch: fetcher }); +): Promise> { + return callZaloApi("setWebhook", token, params, { fetch: fetcher }); } /** @@ -193,8 +219,12 @@ export async function setWebhook( export async function deleteWebhook( token: string, fetcher?: ZaloFetch, -): Promise> { - return callZaloApi("deleteWebhook", token, undefined, { fetch: fetcher }); + timeoutMs?: number, +): Promise> { + return callZaloApi("deleteWebhook", token, undefined, { + timeoutMs, + fetch: fetcher, + }); } /** @@ -203,6 +233,6 @@ export async function deleteWebhook( export async function getWebhookInfo( token: string, fetcher?: ZaloFetch, -): Promise> { - return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher }); +): Promise> { + return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher }); } diff --git a/extensions/zalo/src/channel.startup.test.ts b/extensions/zalo/src/channel.startup.test.ts new file mode 100644 index 0000000000000..65e413f0f4f4d --- /dev/null +++ b/extensions/zalo/src/channel.startup.test.ts @@ -0,0 +1,100 @@ +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import type { ResolvedZaloAccount } from "./accounts.js"; + +const hoisted = vi.hoisted(() => ({ + monitorZaloProvider: vi.fn(), + probeZalo: vi.fn(async () => ({ + ok: false as const, + error: "probe failed", + elapsedMs: 1, + })), +})); + +vi.mock("./monitor.js", async () => { + const actual = await vi.importActual("./monitor.js"); + return { + ...actual, + monitorZaloProvider: hoisted.monitorZaloProvider, + }; +}); + +vi.mock("./probe.js", async () => { + const actual = await vi.importActual("./probe.js"); + return { + ...actual, + probeZalo: hoisted.probeZalo, + }; +}); + +import { zaloPlugin } from "./channel.js"; + +function buildAccount(): ResolvedZaloAccount { + return { + accountId: "default", + enabled: true, + token: "test-token", + tokenSource: "config", + config: {}, + }; +} + +describe("zaloPlugin gateway.startAccount", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("keeps startAccount pending until abort", async () => { + hoisted.monitorZaloProvider.mockImplementationOnce( + async ({ abortSignal }: { abortSignal: AbortSignal }) => + await new Promise((resolve) => { + if (abortSignal.aborted) { + resolve(); + return; + } + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }), + ); + + const patches: ChannelAccountSnapshot[] = []; + const abort = new AbortController(); + const task = zaloPlugin.gateway!.startAccount!( + createStartAccountContext({ + account: buildAccount(), + abortSignal: abort.signal, + statusPatchSink: (next) => patches.push({ ...next }), + }), + ); + + let settled = false; + void task.then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(hoisted.probeZalo).toHaveBeenCalledOnce(); + expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce(); + }); + + expect(settled).toBe(false); + expect(patches).toContainEqual( + expect.objectContaining({ + accountId: "default", + }), + ); + + abort.abort(); + await task; + + expect(settled).toBe(true); + expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith( + expect.objectContaining({ + token: "test-token", + account: expect.objectContaining({ accountId: "default" }), + abortSignal: abort.signal, + useWebhook: false, + }), + ); + }); +}); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 296f81d765ce1..e4671bb90c1ee 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -334,6 +334,7 @@ export const zaloPlugin: ChannelPlugin = { startAccount: async (ctx) => { const account = ctx.account; const token = account.token.trim(); + const mode = account.config.webhookUrl ? "webhook" : "polling"; let zaloBotLabel = ""; const fetcher = resolveZaloProxyFetch(account.config.proxy); try { @@ -342,14 +343,21 @@ export const zaloPlugin: ChannelPlugin = { if (name) { zaloBotLabel = ` (${name})`; } + if (!probe.ok) { + ctx.log?.warn?.( + `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`, + ); + } ctx.setStatus({ accountId: account.accountId, bot: probe.bot, }); - } catch { - // ignore probe errors + } catch (err) { + ctx.log?.warn?.( + `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); } - ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`); + ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`); const { monitorZaloProvider } = await import("./monitor.js"); return monitorZaloProvider({ token, diff --git a/extensions/zalo/src/monitor.lifecycle.test.ts b/extensions/zalo/src/monitor.lifecycle.test.ts new file mode 100644 index 0000000000000..6cce789da565b --- /dev/null +++ b/extensions/zalo/src/monitor.lifecycle.test.ts @@ -0,0 +1,213 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; +import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +import type { ResolvedZaloAccount } from "./accounts.js"; + +const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } })); +const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } })); +const getUpdatesMock = vi.fn(() => new Promise(() => {})); +const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } })); + +vi.mock("./api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteWebhook: deleteWebhookMock, + getWebhookInfo: getWebhookInfoMock, + getUpdates: getUpdatesMock, + setWebhook: setWebhookMock, + }; +}); + +vi.mock("./runtime.js", () => ({ + getZaloRuntime: () => ({ + logging: { + shouldLogVerbose: () => false, + }, + }), +})); + +async function waitForPollingLoopStart(): Promise { + await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1)); +} + +describe("monitorZaloProvider lifecycle", () => { + afterEach(() => { + vi.clearAllMocks(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("stays alive in polling mode until abort", async () => { + const { monitorZaloProvider } = await import("./monitor.js"); + const abort = new AbortController(); + const runtime = { + log: vi.fn<(message: string) => void>(), + error: vi.fn<(message: string) => void>(), + }; + const account = { + accountId: "default", + config: {}, + } as unknown as ResolvedZaloAccount; + const config = {} as OpenClawConfig; + + let settled = false; + const run = monitorZaloProvider({ + token: "test-token", + account, + config, + runtime, + abortSignal: abort.signal, + }).then(() => { + settled = true; + }); + + await waitForPollingLoopStart(); + + expect(getWebhookInfoMock).toHaveBeenCalledTimes(1); + expect(deleteWebhookMock).not.toHaveBeenCalled(); + expect(getUpdatesMock).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + abort.abort(); + await run; + + expect(settled).toBe(true); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Zalo provider stopped mode=polling"), + ); + }); + + it("deletes an existing webhook before polling", async () => { + getWebhookInfoMock.mockResolvedValueOnce({ + ok: true, + result: { url: "https://example.com/hooks/zalo" }, + }); + + const { monitorZaloProvider } = await import("./monitor.js"); + const abort = new AbortController(); + const runtime = { + log: vi.fn<(message: string) => void>(), + error: vi.fn<(message: string) => void>(), + }; + const account = { + accountId: "default", + config: {}, + } as unknown as ResolvedZaloAccount; + const config = {} as OpenClawConfig; + + const run = monitorZaloProvider({ + token: "test-token", + account, + config, + runtime, + abortSignal: abort.signal, + }); + + await waitForPollingLoopStart(); + + expect(getWebhookInfoMock).toHaveBeenCalledTimes(1); + expect(deleteWebhookMock).toHaveBeenCalledTimes(1); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Zalo polling mode ready (webhook disabled)"), + ); + + abort.abort(); + await run; + }); + + it("continues polling when webhook inspection returns 404", async () => { + const { ZaloApiError } = await import("./api.js"); + getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found")); + + const { monitorZaloProvider } = await import("./monitor.js"); + const abort = new AbortController(); + const runtime = { + log: vi.fn<(message: string) => void>(), + error: vi.fn<(message: string) => void>(), + }; + const account = { + accountId: "default", + config: {}, + } as unknown as ResolvedZaloAccount; + const config = {} as OpenClawConfig; + + const run = monitorZaloProvider({ + token: "test-token", + account, + config, + runtime, + abortSignal: abort.signal, + }); + + await waitForPollingLoopStart(); + + expect(getWebhookInfoMock).toHaveBeenCalledTimes(1); + expect(deleteWebhookMock).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"), + ); + expect(runtime.error).not.toHaveBeenCalled(); + + abort.abort(); + await run; + }); + + it("waits for webhook deletion before finishing webhook shutdown", async () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry); + + let resolveDeleteWebhook: (() => void) | undefined; + deleteWebhookMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } }); + }), + ); + + const { monitorZaloProvider } = await import("./monitor.js"); + const abort = new AbortController(); + const runtime = { + log: vi.fn<(message: string) => void>(), + error: vi.fn<(message: string) => void>(), + }; + const account = { + accountId: "default", + config: {}, + } as unknown as ResolvedZaloAccount; + const config = {} as OpenClawConfig; + + let settled = false; + const run = monitorZaloProvider({ + token: "test-token", + account, + config, + runtime, + abortSignal: abort.signal, + useWebhook: true, + webhookUrl: "https://example.com/hooks/zalo", + webhookSecret: "supersecret", // pragma: allowlist secret + }).then(() => { + settled = true; + }); + + await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1)); + expect(registry.httpRoutes).toHaveLength(1); + + abort.abort(); + + await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1)); + expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000); + expect(settled).toBe(false); + expect(registry.httpRoutes).toHaveLength(1); + + resolveDeleteWebhook?.(); + await run; + + expect(settled).toBe(true); + expect(registry.httpRoutes).toHaveLength(0); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Zalo provider stopped mode=webhook"), + ); + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 33692f27bbb85..bd1351bd147cc 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -5,9 +5,11 @@ import type { OutboundReplyPayload, } from "openclaw/plugin-sdk/zalo"; import { + createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, issuePairingChallenge, + logTypingFailure, resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, resolveOutboundMediaUrls, @@ -15,13 +17,16 @@ import { resolveInboundRouteEnvelopeBuilderWithRuntime, sendMediaWithLeadingCaption, resolveWebhookPath, + waitForAbortSignal, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/zalo"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, deleteWebhook, + getWebhookInfo, getUpdates, + sendChatAction, sendMessage, sendPhoto, setWebhook, @@ -64,15 +69,34 @@ export type ZaloMonitorOptions = { statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }; -export type ZaloMonitorResult = { - stop: () => void; -}; - const ZALO_TEXT_LIMIT = 2000; const DEFAULT_MEDIA_MAX_MB = 5; +const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000; +const ZALO_TYPING_TIMEOUT_MS = 5_000; type ZaloCoreRuntime = ReturnType; +function formatZaloError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}`; + } + return String(error); +} + +function describeWebhookTarget(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return rawUrl; + } +} + +function normalizeWebhookUrl(url: string | undefined): string | undefined { + const trimmed = url?.trim(); + return trimmed ? trimmed : undefined; +} + function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void { if (core.logging.shouldLogVerbose()) { runtime.log?.(`[zalo] ${message}`); @@ -151,6 +175,8 @@ function startPollingLoop(params: { } = params; const pollTimeout = 30; + runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`); + const poll = async () => { if (isStopped() || abortSignal.aborted) { return; @@ -176,7 +202,7 @@ function startPollingLoop(params: { if (err instanceof ZaloApiError && err.isPollingTimeout) { // no updates } else if (!isStopped() && !abortSignal.aborted) { - runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`); + runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`); await new Promise((resolve) => setTimeout(resolve, 5000)); } } @@ -522,12 +548,35 @@ async function processMessageWithPipeline(params: { channel: "zalo", accountId: account.accountId, }); + const typingCallbacks = createTypingCallbacks({ + start: async () => { + await sendChatAction( + token, + { + chat_id: chatId, + action: "typing", + }, + fetcher, + ZALO_TYPING_TIMEOUT_MS, + ); + }, + onStartError: (err) => { + logTypingFailure({ + log: (message) => logVerbose(core, runtime, message), + channel: "zalo", + action: "start", + target: chatId, + error: err, + }); + }, + }); await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, dispatcherOptions: { ...prefixOptions, + typingCallbacks, deliver: async (payload) => { await deliverZaloReply({ payload, @@ -567,7 +616,6 @@ async function deliverZaloReply(params: { const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const sentMedia = await sendMediaWithLeadingCaption({ mediaUrls: resolveOutboundMediaUrls(payload), caption: text, @@ -597,7 +645,7 @@ async function deliverZaloReply(params: { } } -export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise { +export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise { const { token, account, @@ -615,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< const core = getZaloRuntime(); const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy); + const mode = useWebhook ? "webhook" : "polling"; let stopped = false; const stopHandlers: Array<() => void> = []; + let cleanupWebhook: (() => Promise) | undefined; const stop = () => { + if (stopped) { + return; + } stopped = true; for (const handler of stopHandlers) { handler(); } }; - if (useWebhook) { - if (!webhookUrl || !webhookSecret) { - throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode"); - } - if (!webhookUrl.startsWith("https://")) { - throw new Error("Zalo webhook URL must use HTTPS"); - } - if (webhookSecret.length < 8 || webhookSecret.length > 256) { - throw new Error("Zalo webhook secret must be 8-256 characters"); - } + runtime.log?.( + `[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`, + ); + + try { + if (useWebhook) { + if (!webhookUrl || !webhookSecret) { + throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode"); + } + if (!webhookUrl.startsWith("https://")) { + throw new Error("Zalo webhook URL must use HTTPS"); + } + if (webhookSecret.length < 8 || webhookSecret.length > 256) { + throw new Error("Zalo webhook secret must be 8-256 characters"); + } + + const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null }); + if (!path) { + throw new Error("Zalo webhookPath could not be derived"); + } + + runtime.log?.( + `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`, + ); + await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher); + let webhookCleanupPromise: Promise | undefined; + cleanupWebhook = async () => { + if (!webhookCleanupPromise) { + webhookCleanupPromise = (async () => { + runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`); + try { + await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS); + runtime.log?.(`[${account.accountId}] Zalo webhook deleted`); + } catch (err) { + const detail = + err instanceof Error && err.name === "AbortError" + ? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms` + : formatZaloError(err); + runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`); + } + })(); + } + await webhookCleanupPromise; + }; + runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`); - const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null }); - if (!path) { - throw new Error("Zalo webhookPath could not be derived"); + const unregister = registerZaloWebhookTarget({ + token, + account, + config, + runtime, + core, + path, + secret: webhookSecret, + statusSink: (patch) => statusSink?.(patch), + mediaMaxMb: effectiveMediaMaxMb, + fetcher, + }); + stopHandlers.push(unregister); + await waitForAbortSignal(abortSignal); + return; } - await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher); + runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`); + try { + try { + const currentWebhookUrl = normalizeWebhookUrl( + (await getWebhookInfo(token, fetcher)).result?.url, + ); + if (!currentWebhookUrl) { + runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`); + } else { + runtime.log?.( + `[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`, + ); + await deleteWebhook(token, fetcher); + runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`); + } + } catch (err) { + if (err instanceof ZaloApiError && err.errorCode === 404) { + // Some Zalo environments do not expose webhook inspection for polling bots. + runtime.log?.( + `[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`, + ); + } else { + throw err; + } + } + } catch (err) { + runtime.error?.( + `[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`, + ); + } - const unregister = registerZaloWebhookTarget({ + startPollingLoop({ token, account, config, runtime, core, - path, - secret: webhookSecret, - statusSink: (patch) => statusSink?.(patch), + abortSignal, + isStopped: () => stopped, mediaMaxMb: effectiveMediaMaxMb, + statusSink, fetcher, }); - stopHandlers.push(unregister); - abortSignal.addEventListener( - "abort", - () => { - void deleteWebhook(token, fetcher).catch(() => {}); - }, - { once: true }, - ); - return { stop }; - } - try { - await deleteWebhook(token, fetcher); - } catch { - // ignore + await waitForAbortSignal(abortSignal); + } catch (err) { + runtime.error?.( + `[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`, + ); + throw err; + } finally { + await cleanupWebhook?.(); + stop(); + runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`); } - - startPollingLoop({ - token, - account, - config, - runtime, - core, - abortSignal, - isStopped: () => stopped, - mediaMaxMb: effectiveMediaMaxMb, - statusSink, - fetcher, - }); - - return { stop }; } export const __testing = { diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 82f484b487723..2196493009efe 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -41,6 +41,8 @@ export type { } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; +export { logTypingFailure } from "../channels/logging.js"; +export { createTypingCallbacks } from "../channels/typing.js"; export type { OpenClawConfig } from "../config/config.js"; export { resolveDefaultGroupPolicy, @@ -56,6 +58,7 @@ export { } from "../config/types.secrets.js"; export { buildSecretInputSchema } from "./secret-input-schema.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; +export { waitForAbortSignal } from "../infra/abort-signal.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; From fa83010b17bb0cc52273b555040b731a29eede91 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:36:41 -0500 Subject: [PATCH 061/311] fix(plugins): ship Feishu bundled runtime dependency (#39990) * fix: ship feishu bundled runtime dependency * test: align feishu bundled dependency specs --- docs/channels/feishu.md | 14 ++++++------- docs/zh-CN/channels/feishu.md | 12 ++++-------- src/plugins/bundled-runtime-deps.test.ts | 25 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 src/plugins/bundled-runtime-deps.test.ts diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 3158599aa86f0..67e4fd603792b 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -12,18 +12,16 @@ Feishu (Lark) is a team chat platform used by companies for messaging and collab --- -## Plugin required +## Bundled plugin -Install the Feishu plugin: +Feishu ships bundled with current OpenClaw releases, so no separate plugin install +is required. -```bash -openclaw plugins install @openclaw/feishu -``` - -Local checkout (when running from a git repo): +If you are using an older build or a custom install that does not include bundled +Feishu, install it manually: ```bash -openclaw plugins install ./extensions/feishu +openclaw plugins install @openclaw/feishu ``` --- diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md index 4cc8b578a6a4d..7a1c198733c1b 100644 --- a/docs/zh-CN/channels/feishu.md +++ b/docs/zh-CN/channels/feishu.md @@ -12,18 +12,14 @@ title: 飞书 --- -## 需要插件 +## 内置插件 -安装 Feishu 插件: +当前版本的 OpenClaw 已内置 Feishu 插件,因此通常不需要单独安装。 -```bash -openclaw plugins install @openclaw/feishu -``` - -本地 checkout(在 git 仓库内运行): +如果你使用的是较旧版本,或是没有内置 Feishu 的自定义安装,可手动安装: ```bash -openclaw plugins install ./extensions/feishu +openclaw plugins install @openclaw/feishu ``` --- diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts new file mode 100644 index 0000000000000..027651c5a0760 --- /dev/null +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +type PackageManifest = { + dependencies?: Record; +}; + +function readJson(relativePath: string): T { + const absolutePath = path.resolve(process.cwd(), relativePath); + return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T; +} + +describe("bundled plugin runtime dependencies", () => { + it("keeps bundled Feishu runtime deps available from the published root package", () => { + const rootManifest = readJson("package.json"); + const feishuManifest = readJson("extensions/feishu/package.json"); + const feishuSpec = feishuManifest.dependencies?.["@larksuiteoapi/node-sdk"]; + const rootSpec = rootManifest.dependencies?.["@larksuiteoapi/node-sdk"]; + + expect(feishuSpec).toBeTruthy(); + expect(rootSpec).toBeTruthy(); + expect(rootSpec).toBe(feishuSpec); + }); +}); From c94265545167b6d3ae18af7516cd6149b7b76c6e Mon Sep 17 00:00:00 2001 From: Hermione Date: Sun, 8 Mar 2026 16:07:28 +0000 Subject: [PATCH 062/311] fix(hooks): use resolveAgentIdFromSessionKey in runBeforeReset (#39875) Merged via squash. Prepared head SHA: 00a2b241df1cf4fa7089d6ee98b5d7e2c7acf105 Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/auto-reply/reply/commands-core.test.ts | 88 +++++++++++++++++++ src/auto-reply/reply/commands-core.ts | 5 +- src/auto-reply/reply/commands.test.ts | 3 + .../bundled/session-memory/handler.test.ts | 62 +++++++++++-- src/hooks/bundled/session-memory/handler.ts | 49 +++++++++-- 6 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 src/auto-reply/reply/commands-core.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d7ae6b64361..d76ab2c1f4b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. - Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. +- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. ## 2026.3.7 diff --git a/src/auto-reply/reply/commands-core.test.ts b/src/auto-reply/reply/commands-core.test.ts new file mode 100644 index 0000000000000..226037f957a0c --- /dev/null +++ b/src/auto-reply/reply/commands-core.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { HookRunner } from "../../plugins/hooks.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +const hookRunnerMocks = vi.hoisted(() => ({ + hasHooks: vi.fn(), + runBeforeReset: vi.fn(), +})); + +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => + ({ + hasHooks: hookRunnerMocks.hasHooks, + runBeforeReset: hookRunnerMocks.runBeforeReset, + }) as unknown as HookRunner, +})); + +const { emitResetCommandHooks } = await import("./commands-core.js"); + +describe("emitResetCommandHooks", () => { + async function runBeforeResetContext(sessionKey?: string) { + const command = { + surface: "discord", + senderId: "rai", + channel: "discord", + from: "discord:rai", + to: "discord:bot", + resetHookTriggered: false, + } as HandleCommandsParams["command"]; + + await emitResetCommandHooks({ + action: "new", + ctx: {} as HandleCommandsParams["ctx"], + cfg: {} as HandleCommandsParams["cfg"], + command, + sessionKey, + previousSessionEntry: { + sessionId: "prev-session", + } as HandleCommandsParams["previousSessionEntry"], + workspaceDir: "/tmp/openclaw-workspace", + }); + + await vi.waitFor(() => expect(hookRunnerMocks.runBeforeReset).toHaveBeenCalledTimes(1)); + const [, ctx] = hookRunnerMocks.runBeforeReset.mock.calls[0] ?? []; + return ctx; + } + + beforeEach(() => { + hookRunnerMocks.hasHooks.mockReset(); + hookRunnerMocks.runBeforeReset.mockReset(); + hookRunnerMocks.hasHooks.mockImplementation((hookName) => hookName === "before_reset"); + hookRunnerMocks.runBeforeReset.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("passes the bound agent id to before_reset hooks for multi-agent session keys", async () => { + const ctx = await runBeforeResetContext("agent:navi:main"); + expect(ctx).toMatchObject({ + agentId: "navi", + sessionKey: "agent:navi:main", + sessionId: "prev-session", + workspaceDir: "/tmp/openclaw-workspace", + }); + }); + + it("falls back to main when the reset hook has no session key", async () => { + const ctx = await runBeforeResetContext(undefined); + expect(ctx).toMatchObject({ + agentId: "main", + sessionKey: undefined, + sessionId: "prev-session", + workspaceDir: "/tmp/openclaw-workspace", + }); + }); + + it("keeps the main-agent path on the main agent workspace", async () => { + const ctx = await runBeforeResetContext("agent:main:main"); + expect(ctx).toMatchObject({ + agentId: "main", + sessionKey: "agent:main:main", + sessionId: "prev-session", + workspaceDir: "/tmp/openclaw-workspace", + }); + }); +}); diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index d57d679fdb600..894724bcfb0a4 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -3,7 +3,7 @@ import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; -import { isAcpSessionKey } from "../../routing/session-key.js"; +import { isAcpSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { shouldHandleTextCommands } from "../commands-registry.js"; import { handleAcpCommand } from "./commands-acp.js"; @@ -63,6 +63,7 @@ export async function emitResetCommandHooks(params: { previousSessionEntry: params.previousSessionEntry, commandSource: params.command.surface, senderId: params.command.senderId, + workspaceDir: params.workspaceDir, cfg: params.cfg, // Pass config for LLM slug generation }); await triggerInternalHook(hookEvent); @@ -120,7 +121,7 @@ export async function emitResetCommandHooks(params: { await hookRunner.runBeforeReset( { sessionFile, messages, reason: params.action }, { - agentId: params.sessionKey?.split(":")[0] ?? "main", + agentId: resolveAgentIdFromSessionKey(params.sessionKey), sessionKey: params.sessionKey, sessionId: prevEntry?.sessionId, workspaceDir: params.workspaceDir, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 2c05690ebd01f..38be7c435315a 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1138,6 +1138,9 @@ describe("handleCommands hooks", () => { type: "command", action: "new", sessionKey: "agent:main:telegram:direct:123", + context: expect.objectContaining({ + workspaceDir: testWorkspaceDir, + }), }), ); spy.mockRestore(); diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 7f29c58b12870..fb7e9ca0a4db5 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -65,15 +65,23 @@ async function runNewWithPreviousSessionEntry(params: { previousSessionEntry: { sessionId: string; sessionFile?: string }; cfg?: OpenClawConfig; action?: "new" | "reset"; + sessionKey?: string; + workspaceDirOverride?: string; }): Promise<{ files: string[]; memoryContent: string }> { - const event = createHookEvent("command", params.action ?? "new", "agent:main:main", { - cfg: - params.cfg ?? - ({ - agents: { defaults: { workspace: params.tempDir } }, - } satisfies OpenClawConfig), - previousSessionEntry: params.previousSessionEntry, - }); + const event = createHookEvent( + "command", + params.action ?? "new", + params.sessionKey ?? "agent:main:main", + { + cfg: + params.cfg ?? + ({ + agents: { defaults: { workspace: params.tempDir } }, + } satisfies OpenClawConfig), + previousSessionEntry: params.previousSessionEntry, + ...(params.workspaceDirOverride ? { workspaceDir: params.workspaceDirOverride } : {}), + }, + ); await handler(event); @@ -242,6 +250,44 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("assistant: Captured before reset"); }); + it("prefers workspaceDir from hook context when sessionKey points at main", async () => { + const mainWorkspace = await createCaseWorkspace("workspace-main"); + const naviWorkspace = await createCaseWorkspace("workspace-navi"); + const naviSessionsDir = path.join(naviWorkspace, "sessions"); + await fs.mkdir(naviSessionsDir, { recursive: true }); + + const sessionFile = await writeWorkspaceFile({ + dir: naviSessionsDir, + name: "navi-session.jsonl", + content: createMockSessionContent([ + { role: "user", content: "Remember this under Navi" }, + { role: "assistant", content: "Stored in the bound workspace" }, + ]), + }); + + const { files, memoryContent } = await runNewWithPreviousSessionEntry({ + tempDir: naviWorkspace, + cfg: { + agents: { + defaults: { workspace: mainWorkspace }, + list: [{ id: "navi", workspace: naviWorkspace }], + }, + } satisfies OpenClawConfig, + sessionKey: "agent:main:main", + workspaceDirOverride: naviWorkspace, + previousSessionEntry: { + sessionId: "navi-session", + sessionFile, + }, + }); + + expect(files.length).toBe(1); + expect(memoryContent).toContain("user: Remember this under Navi"); + expect(memoryContent).toContain("assistant: Stored in the bound workspace"); + expect(memoryContent).toContain("- **Session Key**: agent:navi:main"); + await expect(fs.access(path.join(mainWorkspace, "memory"))).rejects.toThrow(); + }); + it("filters out non-message entries (tool calls, system)", async () => { // Create session with mixed entry types const sessionContent = createMockSessionContent([ diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 79bfa1cf329d5..32fc36b23f09d 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -8,12 +8,19 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; +import { + resolveAgentIdByWorkspacePath, + resolveAgentWorkspaceDir, +} from "../../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { resolveStateDir } from "../../../config/paths.js"; import { writeFileWithinRoot } from "../../../infra/fs-safe.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; -import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import { + parseAgentSessionKey, + resolveAgentIdFromSessionKey, + toAgentStoreSessionKey, +} from "../../../routing/session-key.js"; import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js"; import { resolveHookConfig } from "../../config.js"; import type { HookHandler } from "../../hooks.js"; @@ -21,6 +28,25 @@ import { generateSlugViaLLM } from "../../llm-slug-generator.js"; const log = createSubsystemLogger("hooks/session-memory"); +function resolveDisplaySessionKey(params: { + cfg?: OpenClawConfig; + workspaceDir?: string; + sessionKey: string; +}): string { + if (!params.cfg || !params.workspaceDir) { + return params.sessionKey; + } + const workspaceAgentId = resolveAgentIdByWorkspacePath(params.cfg, params.workspaceDir); + const parsed = parseAgentSessionKey(params.sessionKey); + if (!workspaceAgentId || !parsed || workspaceAgentId === parsed.agentId) { + return params.sessionKey; + } + return toAgentStoreSessionKey({ + agentId: workspaceAgentId, + requestKey: parsed.rest, + }); +} + /** * Read recent messages from session file for slug generation */ @@ -182,10 +208,21 @@ const saveSessionToMemory: HookHandler = async (event) => { const context = event.context || {}; const cfg = context.cfg as OpenClawConfig | undefined; + const contextWorkspaceDir = + typeof context.workspaceDir === "string" && context.workspaceDir.trim().length > 0 + ? context.workspaceDir + : undefined; const agentId = resolveAgentIdFromSessionKey(event.sessionKey); - const workspaceDir = cfg - ? resolveAgentWorkspaceDir(cfg, agentId) - : path.join(resolveStateDir(process.env, os.homedir), "workspace"); + const workspaceDir = + contextWorkspaceDir || + (cfg + ? resolveAgentWorkspaceDir(cfg, agentId) + : path.join(resolveStateDir(process.env, os.homedir), "workspace")); + const displaySessionKey = resolveDisplaySessionKey({ + cfg, + workspaceDir: contextWorkspaceDir, + sessionKey: event.sessionKey, + }); const memoryDir = path.join(workspaceDir, "memory"); await fs.mkdir(memoryDir, { recursive: true }); @@ -293,7 +330,7 @@ const saveSessionToMemory: HookHandler = async (event) => { const entryParts = [ `# Session: ${dateStr} ${timeStr} UTC`, "", - `- **Session Key**: ${event.sessionKey}`, + `- **Session Key**: ${displaySessionKey}`, `- **Session ID**: ${sessionId}`, `- **Source**: ${source}`, "", From ca5e352c53e8794c05339ba6a0b0b8f5ba6bf629 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 8 Mar 2026 19:10:48 +0300 Subject: [PATCH 063/311] CLI: include commit hash in --version output (#39712) * CLI: include commit hash in --version output * fix(version): harden commit SHA resolution and keep output consistent * CLI: keep install checks compatible with commit-tagged version output * fix(cli): include commit hash in root version fast path * test(cli): allow null commit-hash mocks * Installer: share version parser across install scripts * Installer: avoid sourcing helpers from stdin cwd * CLI: note commit-tagged version output * CLI: anchor commit hash resolution to module root * CLI: harden commit hash resolution * CLI: fix commit hash lookup edge cases * CLI: prefer live git metadata in dev builds * CLI: keep git lookup inside package root * Infra: tolerate invalid moduleUrl hints * CLI: cache baked commit metadata fallbacks * CLI: align changelog attribution with prep gate * CLI: restore changelog contributor credit --------- Co-authored-by: echoVic Co-authored-by: echoVic --- CHANGELOG.md | 1 + .../docker/install-sh-common/cli-verify.sh | 6 + .../docker/install-sh-common/version-parse.sh | 14 + scripts/docker/install-sh-e2e/Dockerfile | 1 + scripts/docker/install-sh-e2e/run.sh | 9 + scripts/docker/install-sh-nonroot/Dockerfile | 1 + scripts/docker/install-sh-smoke/Dockerfile | 1 + scripts/install.sh | 40 +- src/auto-reply/status.ts | 2 +- src/cli/banner.ts | 3 +- src/cli/program/help.test.ts | 26 +- src/cli/program/help.ts | 6 +- src/entry.ts | 8 +- src/entry.version-fast-path.test.ts | 104 +++++ src/infra/git-commit.test.ts | 402 ++++++++++++++++++ src/infra/git-commit.ts | 182 ++++++-- src/infra/openclaw-root.test.ts | 12 + src/infra/openclaw-root.ts | 6 +- src/install-sh-version.test.ts | 121 ++++++ 19 files changed, 903 insertions(+), 42 deletions(-) create mode 100644 scripts/docker/install-sh-common/version-parse.sh create mode 100644 src/entry.version-fast-path.test.ts create mode 100644 src/infra/git-commit.test.ts create mode 100644 src/install-sh-version.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d76ab2c1f4b45..64a0571cb7a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. +- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. ### Fixes diff --git a/scripts/docker/install-sh-common/cli-verify.sh b/scripts/docker/install-sh-common/cli-verify.sh index 98d08cfe4bfcc..2781b18cca1fd 100644 --- a/scripts/docker/install-sh-common/cli-verify.sh +++ b/scripts/docker/install-sh-common/cli-verify.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./version-parse.sh +source "$SCRIPT_DIR/version-parse.sh" + verify_installed_cli() { local package_name="$1" local expected_version="$2" @@ -32,6 +36,8 @@ verify_installed_cli() { installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')" fi + installed_version="$(extract_openclaw_semver "$installed_version")" + echo "cli=$cli_name installed=$installed_version expected=$expected_version" if [[ "$installed_version" != "$expected_version" ]]; then echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2 diff --git a/scripts/docker/install-sh-common/version-parse.sh b/scripts/docker/install-sh-common/version-parse.sh new file mode 100644 index 0000000000000..b56c200f47c29 --- /dev/null +++ b/scripts/docker/install-sh-common/version-parse.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +extract_openclaw_semver() { + local raw="${1:-}" + local parsed="" + parsed="$( + printf '%s\n' "$raw" \ + | tr -d '\r' \ + | grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \ + | head -n 1 \ + || true + )" + printf '%s' "${parsed#v}" +} diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index ae7049bd31095..26b69b0b7ef4c 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update \ git \ && rm -rf /var/lib/apt/lists/* +COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY run.sh /usr/local/bin/openclaw-install-e2e RUN chmod +x /usr/local/bin/openclaw-install-e2e diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh index 4873436b057b5..6475fe9a914fa 100755 --- a/scripts/docker/install-sh-e2e/run.sh +++ b/scripts/docker/install-sh-e2e/run.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh" +if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then + VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh" +fi +# shellcheck source=../install-sh-common/version-parse.sh +source "$VERIFY_HELPER_PATH" + INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}" MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}" @@ -69,6 +77,7 @@ fi echo "==> Verify installed version" INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')" +INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")" echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION" if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2 diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 2e9c604d3a152..5543ef84882da 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -27,6 +27,7 @@ ENV NPM_CONFIG_FUND=false ENV NPM_CONFIG_AUDIT=false COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh +COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index be6b3b0f6ee2d..ee3221607fbc3 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -19,6 +19,7 @@ RUN set -eux; \ && rm -rf /var/lib/apt/lists/* COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh +COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke RUN chmod +x /usr/local/bin/openclaw-install-smoke diff --git a/scripts/install.sh b/scripts/install.sh index 70d794b97e35e..f7f134907964d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2085,14 +2085,52 @@ run_bootstrap_onboarding_if_needed() { } } +load_install_version_helpers() { + local source_path="${BASH_SOURCE[0]-}" + local script_dir="" + local helper_path="" + if [[ -z "$source_path" || ! -f "$source_path" ]]; then + return 0 + fi + script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null || true)" + helper_path="${script_dir}/docker/install-sh-common/version-parse.sh" + if [[ -n "$script_dir" && -r "$helper_path" ]]; then + # shellcheck source=docker/install-sh-common/version-parse.sh + source "$helper_path" + fi +} + +load_install_version_helpers + +if ! declare -F extract_openclaw_semver >/dev/null 2>&1; then +# Inline fallback when version-parse.sh could not be sourced (for example, stdin install). +extract_openclaw_semver() { + local raw="${1:-}" + local parsed="" + parsed="$( + printf '%s\n' "$raw" \ + | tr -d '\r' \ + | grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \ + | head -n 1 \ + || true + )" + printf '%s' "${parsed#v}" +} +fi + resolve_openclaw_version() { local version="" + local raw_version_output="" local claw="${OPENCLAW_BIN:-}" if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then claw="$(command -v openclaw)" fi if [[ -n "$claw" ]]; then - version=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r') + raw_version_output=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r') + version="$(extract_openclaw_semver "$raw_version_output")" + if [[ -z "$version" ]]; then + version="$raw_version_output" + fi fi if [[ -z "$version" ]]; then local npm_root="" diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a08931b1c1cc9..d4c5e0c18bbda 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -655,7 +655,7 @@ export function buildStatusMessage(args: StatusArgs): string { showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : "" } (${fallbackState.reason ?? "selected model unavailable"})` : null; - const commit = resolveCommitHash(); + const commit = resolveCommitHash({ moduleUrl: import.meta.url }); const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`; const usagePair = formatUsagePair(inputTokens, outputTokens); const cacheLine = formatCacheLine(inputTokens, cacheRead, cacheWrite); diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 4c9e4b7e48801..07bc16abfa039 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -57,7 +57,8 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined { } export function formatCliBannerLine(version: string, options: BannerOptions = {}): string { - const commit = options.commit ?? resolveCommitHash({ env: options.env }); + const commit = + options.commit ?? resolveCommitHash({ env: options.env, moduleUrl: import.meta.url }); const commitLabel = commit ?? "unknown"; const tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) }); const rich = options.richTty ?? isRich(); diff --git a/src/cli/program/help.test.ts b/src/cli/program/help.test.ts index 0a68fae5ef648..6acceb5cc41db 100644 --- a/src/cli/program/help.test.ts +++ b/src/cli/program/help.test.ts @@ -5,6 +5,7 @@ import type { ProgramContext } from "./context.js"; const hasEmittedCliBannerMock = vi.fn(() => false); const formatCliBannerLineMock = vi.fn(() => "BANNER-LINE"); const formatDocsLinkMock = vi.fn((_path: string, full: string) => `https://${full}`); +const resolveCommitHashMock = vi.fn<() => string | null>(() => "abc1234"); vi.mock("../../terminal/links.js", () => ({ formatDocsLink: formatDocsLinkMock, @@ -26,6 +27,10 @@ vi.mock("../banner.js", () => ({ hasEmittedCliBanner: hasEmittedCliBannerMock, })); +vi.mock("../../infra/git-commit.js", () => ({ + resolveCommitHash: resolveCommitHashMock, +})); + vi.mock("../cli-name.js", () => ({ resolveCliName: () => "openclaw", replaceCliName: (cmd: string) => cmd, @@ -55,6 +60,7 @@ describe("configureProgramHelp", () => { vi.clearAllMocks(); originalArgv = [...process.argv]; hasEmittedCliBannerMock.mockReturnValue(false); + resolveCommitHashMock.mockReturnValue("abc1234"); }); afterEach(() => { @@ -116,7 +122,25 @@ describe("configureProgramHelp", () => { const program = makeProgramWithCommands(); expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0"); - expect(logSpy).toHaveBeenCalledWith("9.9.9-test"); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exitSpy).toHaveBeenCalledWith(0); + + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("prints version and exits immediately without commit metadata", () => { + process.argv = ["node", "openclaw", "--version"]; + resolveCommitHashMock.mockReturnValue(null); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code ?? ""}`); + }) as typeof process.exit); + + const program = makeProgramWithCommands(); + expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0"); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); expect(exitSpy).toHaveBeenCalledWith(0); logSpy.mockRestore(); diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 87ef63d8d2e04..c22ea7c832208 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { resolveCommitHash } from "../../infra/git-commit.js"; import { formatDocsLink } from "../../terminal/links.js"; import { isRich, theme } from "../../terminal/theme.js"; import { escapeRegExp } from "../../utils.js"; @@ -109,7 +110,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { hasFlag(process.argv, "--version") || hasRootVersionAlias(process.argv) ) { - console.log(ctx.programVersion); + const commit = resolveCommitHash({ moduleUrl: import.meta.url }); + console.log( + commit ? `OpenClaw ${ctx.programVersion} (${commit})` : `OpenClaw ${ctx.programVersion}`, + ); process.exit(0); } diff --git a/src/entry.ts b/src/entry.ts index 25f91d629210d..50b08029d05bd 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -127,9 +127,11 @@ if ( if (!isRootVersionInvocation(argv)) { return false; } - import("./version.js") - .then(({ VERSION }) => { - console.log(VERSION); + Promise.all([import("./version.js"), import("./infra/git-commit.js")]) + .then(([{ VERSION }, { resolveCommitHash }]) => { + const commit = resolveCommitHash({ moduleUrl: import.meta.url }); + console.log(commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`); + process.exit(0); }) .catch((error) => { console.error( diff --git a/src/entry.version-fast-path.test.ts b/src/entry.version-fast-path.test.ts new file mode 100644 index 0000000000000..a7aa0bad6722b --- /dev/null +++ b/src/entry.version-fast-path.test.ts @@ -0,0 +1,104 @@ +import process from "node:process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const applyCliProfileEnvMock = vi.hoisted(() => vi.fn()); +const attachChildProcessBridgeMock = vi.hoisted(() => vi.fn()); +const installProcessWarningFilterMock = vi.hoisted(() => vi.fn()); +const isMainModuleMock = vi.hoisted(() => vi.fn(() => true)); +const isRootHelpInvocationMock = vi.hoisted(() => vi.fn(() => false)); +const isRootVersionInvocationMock = vi.hoisted(() => vi.fn(() => true)); +const normalizeEnvMock = vi.hoisted(() => vi.fn()); +const normalizeWindowsArgvMock = vi.hoisted(() => vi.fn((argv: string[]) => argv)); +const parseCliProfileArgsMock = vi.hoisted(() => vi.fn((argv: string[]) => ({ ok: true, argv }))); +const resolveCommitHashMock = vi.hoisted(() => vi.fn<() => string | null>(() => "abc1234")); +const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => true)); + +vi.mock("./cli/argv.js", () => ({ + isRootHelpInvocation: isRootHelpInvocationMock, + isRootVersionInvocation: isRootVersionInvocationMock, +})); + +vi.mock("./cli/profile.js", () => ({ + applyCliProfileEnv: applyCliProfileEnvMock, + parseCliProfileArgs: parseCliProfileArgsMock, +})); + +vi.mock("./cli/respawn-policy.js", () => ({ + shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock, +})); + +vi.mock("./cli/windows-argv.js", () => ({ + normalizeWindowsArgv: normalizeWindowsArgvMock, +})); + +vi.mock("./infra/env.js", () => ({ + isTruthyEnvValue: () => false, + normalizeEnv: normalizeEnvMock, +})); + +vi.mock("./infra/git-commit.js", () => ({ + resolveCommitHash: resolveCommitHashMock, +})); + +vi.mock("./infra/is-main.js", () => ({ + isMainModule: isMainModuleMock, +})); + +vi.mock("./infra/warning-filter.js", () => ({ + installProcessWarningFilter: installProcessWarningFilterMock, +})); + +vi.mock("./process/child-process-bridge.js", () => ({ + attachChildProcessBridge: attachChildProcessBridgeMock, +})); + +vi.mock("./version.js", () => ({ + VERSION: "9.9.9-test", +})); + +describe("entry root version fast path", () => { + let originalArgv: string[]; + let exitSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + originalArgv = [...process.argv]; + process.argv = ["node", "openclaw", "--version"]; + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined) as typeof process.exit); + }); + + afterEach(() => { + process.argv = originalArgv; + exitSpy.mockRestore(); + }); + + it("prints commit-tagged version output when commit metadata is available", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await import("./entry.js"); + + await vi.waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + logSpy.mockRestore(); + }); + + it("falls back to plain version output when commit metadata is unavailable", async () => { + resolveCommitHashMock.mockReturnValueOnce(null); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await import("./entry.js"); + + await vi.waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + logSpy.mockRestore(); + }); +}); diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts new file mode 100644 index 0000000000000..d0905e8fb8462 --- /dev/null +++ b/src/infra/git-commit.test.ts @@ -0,0 +1,402 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function makeTempDir(label: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); +} + +async function makeFakeGitRepo( + root: string, + options: { + head: string; + refs?: Record; + gitdir?: string; + commondir?: string; + }, +) { + await fs.mkdir(root, { recursive: true }); + const gitdir = options.gitdir ?? path.join(root, ".git"); + if (options.gitdir) { + await fs.writeFile(path.join(root, ".git"), `gitdir: ${options.gitdir}\n`, "utf-8"); + } else { + await fs.mkdir(gitdir, { recursive: true }); + } + await fs.mkdir(gitdir, { recursive: true }); + await fs.writeFile(path.join(gitdir, "HEAD"), options.head, "utf-8"); + if (options.commondir) { + await fs.writeFile(path.join(gitdir, "commondir"), options.commondir, "utf-8"); + } + for (const [refPath, commit] of Object.entries(options.refs ?? {})) { + const targetPath = path.join(gitdir, refPath); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, `${commit}\n`, "utf-8"); + } +} + +describe("git commit resolution", () => { + const originalCwd = process.cwd(); + + afterEach(() => { + process.chdir(originalCwd); + vi.resetModules(); + }); + + it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { + const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: originalCwd, + encoding: "utf-8", + }).trim(); + + const temp = await makeTempDir("git-commit-cwd"); + const otherRepo = path.join(temp, "other"); + await fs.mkdir(otherRepo, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: otherRepo }); + await fs.writeFile(path.join(otherRepo, "note.txt"), "x\n", "utf-8"); + execFileSync("git", ["add", "note.txt"], { cwd: otherRepo }); + execFileSync( + "git", + ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"], + { cwd: otherRepo }, + ); + const otherHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: otherRepo, + encoding: "utf-8", + }).trim(); + + process.chdir(otherRepo); + const { resolveCommitHash } = await import("./git-commit.js"); + const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; + + expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).toBe(repoHead); + expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).not.toBe(otherHead); + }); + + it("prefers live git metadata over stale build info in a real checkout", async () => { + const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: originalCwd, + encoding: "utf-8", + }).trim(); + + vi.doMock("node:module", () => ({ + createRequire: () => { + return (specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }; + }, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; + + expect(resolveCommitHash({ moduleUrl: entryModuleUrl, env: {} })).toBe(repoHead); + + vi.doUnmock("node:module"); + }); + + it("caches build-info fallback results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-build-info-cache"); + const moduleRequire = vi.fn((specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }); + + vi.doMock("node:module", () => ({ + createRequire: () => moduleRequire, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); + const firstCallRequires = moduleRequire.mock.calls.length; + expect(firstCallRequires).toBeGreaterThan(0); + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); + expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); + + vi.doUnmock("node:module"); + }); + + it("caches package.json fallback results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-package-json-cache"); + const moduleRequire = vi.fn((specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + } + if (specifier === "../../package.json") { + return { gitHead: "badc0ffee0ddf00d" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }); + + vi.doMock("node:module", () => ({ + createRequire: () => moduleRequire, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); + const firstCallRequires = moduleRequire.mock.calls.length; + expect(firstCallRequires).toBeGreaterThan(0); + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); + expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); + + vi.doUnmock("node:module"); + }); + + it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => { + const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: originalCwd, + encoding: "utf-8", + }).trim(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(() => + resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} }), + ).not.toThrow(); + expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} })).toBe( + repoHead, + ); + }); + + it("does not walk out of the openclaw package into a host repo", async () => { + const temp = await makeTempDir("git-commit-package-boundary"); + const hostRepo = path.join(temp, "host"); + await fs.mkdir(hostRepo, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: hostRepo }); + await fs.writeFile(path.join(hostRepo, "host.txt"), "x\n", "utf-8"); + execFileSync("git", ["add", "host.txt"], { cwd: hostRepo }); + execFileSync( + "git", + ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"], + { cwd: hostRepo }, + ); + + const packageRoot = path.join(hostRepo, "node_modules", "openclaw"); + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.3.8" }), + "utf-8", + ); + const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; + + vi.doMock("node:module", () => ({ + createRequire: () => { + return (specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + return { commit: "feedfacefeedfacefeedfacefeedfacefeedface" }; + } + if (specifier === "../../package.json") { + return { name: "openclaw", version: "2026.3.8", gitHead: "badc0ffee0ddf00d" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }; + }, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ moduleUrl, cwd: packageRoot, env: {} })).toBe("feedfac"); + + vi.doUnmock("node:module"); + }); + + it("caches git lookups per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-cache"); + const repoA = path.join(temp, "repo-a"); + const repoB = path.join(temp, "repo-b"); + await makeFakeGitRepo(repoA, { + head: "0123456789abcdef0123456789abcdef01234567\n", + }); + await makeFakeGitRepo(repoB, { + head: "89abcdef0123456789abcdef0123456789abcdef\n", + }); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); + expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde"); + expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); + }); + + it("caches deterministic null results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-null-cache"); + const repoRoot = path.join(temp, "repo"); + await makeFakeGitRepo(repoRoot, { + head: "not-a-commit\n", + }); + + const actualFs = await vi.importActual("node:fs"); + const readFileSyncSpy = vi.fn(actualFs.readFileSync); + vi.doMock("node:fs", () => ({ + ...actualFs, + default: { + ...actualFs, + readFileSync: readFileSyncSpy, + }, + readFileSync: readFileSyncSpy, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + const firstCallReads = readFileSyncSpy.mock.calls.length; + expect(firstCallReads).toBeGreaterThan(0); + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + expect(readFileSyncSpy.mock.calls.length).toBe(firstCallReads); + + vi.doUnmock("node:fs"); + }); + + it("caches caught null fallback results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-caught-null-cache"); + const repoRoot = path.join(temp, "repo"); + await makeFakeGitRepo(repoRoot, { + head: "0123456789abcdef0123456789abcdef01234567\n", + }); + const headPath = path.join(repoRoot, ".git", "HEAD"); + + const actualFs = await vi.importActual("node:fs"); + const readFileSyncSpy = vi.fn((filePath: string, ...args: unknown[]) => { + if (path.resolve(filePath) === path.resolve(headPath)) { + const error = Object.assign(new Error(`EACCES: permission denied, open '${filePath}'`), { + code: "EACCES", + }); + throw error; + } + return Reflect.apply(actualFs.readFileSync, actualFs, [filePath, ...args]); + }); + vi.doMock("node:fs", () => ({ + ...actualFs, + default: { + ...actualFs, + readFileSync: readFileSyncSpy, + }, + readFileSync: readFileSyncSpy, + })); + vi.doMock("node:module", () => ({ + createRequire: () => { + return (specifier: string) => { + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }; + }, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + const headReadCount = () => + readFileSyncSpy.mock.calls.filter(([filePath]) => path.resolve(filePath) === headPath).length; + const firstCallReads = headReadCount(); + expect(firstCallReads).toBe(2); + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + expect(headReadCount()).toBe(firstCallReads); + + vi.doUnmock("node:fs"); + vi.doUnmock("node:module"); + }); + + it("formats env-provided commit strings consistently", async () => { + const temp = await makeTempDir("git-commit-env"); + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe( + "abcdef0", + ); + expect( + resolveCommitHash({ cwd: temp, env: { GIT_SHA: "commit abcdef0123456789 dirty" } }), + ).toBe("abcdef0"); + expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "not-a-sha" } })).toBeNull(); + expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "" } })).toBeNull(); + }); + + it("rejects unsafe HEAD refs and accepts valid refs", async () => { + const temp = await makeTempDir("git-commit-refs"); + const { resolveCommitHash } = await import("./git-commit.js"); + + const absoluteRepo = path.join(temp, "absolute"); + await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" }); + expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull(); + + const traversalRepo = path.join(temp, "traversal"); + await makeFakeGitRepo(traversalRepo, { head: "ref: refs/heads/../evil\n" }); + expect(resolveCommitHash({ cwd: traversalRepo, env: {} })).toBeNull(); + + const invalidPrefixRepo = path.join(temp, "invalid-prefix"); + await makeFakeGitRepo(invalidPrefixRepo, { head: "ref: heads/main\n" }); + expect(resolveCommitHash({ cwd: invalidPrefixRepo, env: {} })).toBeNull(); + + const validRepo = path.join(temp, "valid"); + await makeFakeGitRepo(validRepo, { + head: "ref: refs/heads/main\n", + refs: { + "refs/heads/main": "fedcba9876543210fedcba9876543210fedcba98", + }, + }); + expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("fedcba9"); + }); + + it("resolves refs from the git commondir in worktree layouts", async () => { + const temp = await makeTempDir("git-commit-worktree"); + const repoRoot = path.join(temp, "repo"); + const worktreeGitDir = path.join(temp, "worktree-git"); + const commonGitDir = path.join(temp, "common-git"); + await fs.mkdir(commonGitDir, { recursive: true }); + const refPath = path.join(commonGitDir, "refs", "heads", "main"); + await fs.mkdir(path.dirname(refPath), { recursive: true }); + await fs.writeFile(refPath, "76543210fedcba9876543210fedcba9876543210\n", "utf-8"); + await makeFakeGitRepo(repoRoot, { + gitdir: worktreeGitDir, + head: "ref: refs/heads/main\n", + commondir: "../common-git", + }); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("7654321"); + }); + + it("reads full HEAD refs before parsing long branch names", async () => { + const temp = await makeTempDir("git-commit-long-head"); + const repoRoot = path.join(temp, "repo"); + const longRefName = `refs/heads/${"segment/".repeat(40)}main`; + await makeFakeGitRepo(repoRoot, { + head: `ref: ${longRefName}\n`, + refs: { + [longRefName]: "0123456789abcdef0123456789abcdef01234567", + }, + }); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("0123456"); + }); +}); diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index 44778ce5a057b..74e22156e1b71 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { resolveGitHeadPath } from "./git-root.js"; +import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; const formatCommit = (value?: string | null) => { if (!value) { @@ -11,10 +13,127 @@ const formatCommit = (value?: string | null) => { if (!trimmed) { return null; } - return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed; + const match = trimmed.match(/[0-9a-fA-F]{7,40}/); + if (!match) { + return null; + } + return match[0].slice(0, 7).toLowerCase(); +}; + +const cachedGitCommitBySearchDir = new Map(); + +function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +const resolveCommitSearchDir = (options: { cwd?: string; moduleUrl?: string }) => { + if (options.cwd) { + return path.resolve(options.cwd); + } + if (options.moduleUrl) { + try { + return path.dirname(fileURLToPath(options.moduleUrl)); + } catch { + // moduleUrl is not a valid file:// URL; fall back to process.cwd(). + } + } + return process.cwd(); +}; + +/** Read at most `limit` bytes from a file to avoid unbounded reads. */ +const safeReadFilePrefix = (filePath: string, limit = 256) => { + const fd = fs.openSync(filePath, "r"); + try { + const buf = Buffer.alloc(limit); + const bytesRead = fs.readSync(fd, buf, 0, limit, 0); + return buf.subarray(0, bytesRead).toString("utf-8"); + } finally { + fs.closeSync(fd); + } +}; + +const cacheGitCommit = (searchDir: string, commit: string | null) => { + cachedGitCommitBySearchDir.set(searchDir, commit); + return commit; +}; + +const resolveGitLookupDepth = (searchDir: string, packageRoot: string | null) => { + if (!packageRoot) { + return undefined; + } + const relative = path.relative(packageRoot, searchDir); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return undefined; + } + const depth = relative ? relative.split(path.sep).filter(Boolean).length : 0; + return depth + 1; }; -let cachedCommit: string | null | undefined; +const readCommitFromGit = ( + searchDir: string, + packageRoot: string | null, +): string | null | undefined => { + const headPath = resolveGitHeadPath(searchDir, { + maxDepth: resolveGitLookupDepth(searchDir, packageRoot), + }); + if (!headPath) { + return undefined; + } + const head = fs.readFileSync(headPath, "utf-8").trim(); + if (!head) { + return null; + } + if (head.startsWith("ref:")) { + const ref = head.replace(/^ref:\s*/i, "").trim(); + const refPath = resolveRefPath(headPath, ref); + if (!refPath) { + return null; + } + const refHash = safeReadFilePrefix(refPath).trim(); + return formatCommit(refHash); + } + return formatCommit(head); +}; + +const resolveGitRefsBase = (headPath: string) => { + const gitDir = path.dirname(headPath); + try { + const commonDir = safeReadFilePrefix(path.join(gitDir, "commondir")).trim(); + if (commonDir) { + return path.resolve(gitDir, commonDir); + } + } catch (error) { + if (!isMissingPathError(error)) { + throw error; + } + // Plain repo git dirs do not have commondir. + } + return gitDir; +}; + +/** Safely resolve a git ref path, rejecting traversal attacks from a crafted HEAD file. */ +const resolveRefPath = (headPath: string, ref: string) => { + if (!ref.startsWith("refs/")) { + return null; + } + if (path.isAbsolute(ref)) { + return null; + } + if (ref.split(/[/]/).includes("..")) { + return null; + } + const refsBase = resolveGitRefsBase(headPath); + const resolved = path.resolve(refsBase, ref); + const rel = path.relative(refsBase, resolved); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { + return null; + } + return resolved; +}; const readCommitFromPackageJson = () => { try { @@ -52,49 +171,46 @@ const readCommitFromBuildInfo = () => { } }; -export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => { - if (cachedCommit !== undefined) { - return cachedCommit; - } +export const resolveCommitHash = ( + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + moduleUrl?: string; + } = {}, +) => { const env = options.env ?? process.env; const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); const normalized = formatCommit(envCommit); if (normalized) { - cachedCommit = normalized; - return cachedCommit; + return normalized; + } + const searchDir = resolveCommitSearchDir(options); + if (cachedGitCommitBySearchDir.has(searchDir)) { + return cachedGitCommitBySearchDir.get(searchDir) ?? null; + } + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: options.cwd, + moduleUrl: options.moduleUrl, + }); + try { + const gitCommit = readCommitFromGit(searchDir, packageRoot); + if (gitCommit !== undefined) { + return cacheGitCommit(searchDir, gitCommit); + } + } catch { + // Fall through to baked metadata for packaged installs that are not in a live checkout. } const buildInfoCommit = readCommitFromBuildInfo(); if (buildInfoCommit) { - cachedCommit = buildInfoCommit; - return cachedCommit; + return cacheGitCommit(searchDir, buildInfoCommit); } const pkgCommit = readCommitFromPackageJson(); if (pkgCommit) { - cachedCommit = pkgCommit; - return cachedCommit; + return cacheGitCommit(searchDir, pkgCommit); } try { - const headPath = resolveGitHeadPath(options.cwd ?? process.cwd()); - if (!headPath) { - cachedCommit = null; - return cachedCommit; - } - const head = fs.readFileSync(headPath, "utf-8").trim(); - if (!head) { - cachedCommit = null; - return cachedCommit; - } - if (head.startsWith("ref:")) { - const ref = head.replace(/^ref:\s*/i, "").trim(); - const refPath = path.resolve(path.dirname(headPath), ref); - const refHash = fs.readFileSync(refPath, "utf-8").trim(); - cachedCommit = formatCommit(refHash); - return cachedCommit; - } - cachedCommit = formatCommit(head); - return cachedCommit; + return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null); } catch { - cachedCommit = null; - return cachedCommit; + return cacheGitCommit(searchDir, null); } }; diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index 9caf5cf5d22a7..85d24512468d3 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -141,6 +141,18 @@ describe("resolveOpenClawPackageRoot", () => { expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot); }); + it("ignores invalid moduleUrl values and falls back to cwd", async () => { + const pkgRoot = fx("invalid-moduleurl"); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + + expect(resolveOpenClawPackageRootSync({ moduleUrl: "not-a-file-url", cwd: pkgRoot })).toBe( + pkgRoot, + ); + await expect( + resolveOpenClawPackageRoot({ moduleUrl: "not-a-file-url", cwd: pkgRoot }), + ).resolves.toBe(pkgRoot); + }); + it("returns null for non-openclaw package roots", async () => { const pkgRoot = fx("not-openclaw"); setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" })); diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 5d48c6cb01734..55b6bf7b91aca 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -116,7 +116,11 @@ function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: strin const candidates: string[] = []; if (opts.moduleUrl) { - candidates.push(path.dirname(fileURLToPath(opts.moduleUrl))); + try { + candidates.push(path.dirname(fileURLToPath(opts.moduleUrl))); + } catch { + // Ignore invalid file:// URLs and keep other package-root hints. + } } if (opts.argv1) { candidates.push(...candidateDirsFromArgv1(opts.argv1)); diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts new file mode 100644 index 0000000000000..4a7135925b84e --- /dev/null +++ b/src/install-sh-version.test.ts @@ -0,0 +1,121 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +function withFakeCli(versionOutput: string): { root: string; cliPath: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-sh-")); + const cliPath = path.join(root, "openclaw"); + const escapedOutput = versionOutput.replace(/'/g, "'\\''"); + fs.writeFileSync( + cliPath, + `#!/usr/bin/env bash +printf '%s\n' '${escapedOutput}' +`, + "utf-8", + ); + fs.chmodSync(cliPath, 0o755); + return { root, cliPath }; +} + +function resolveVersionFromInstaller(cliPath: string): string { + const installerPath = path.join(process.cwd(), "scripts", "install.sh"); + const output = execFileSync( + "bash", + [ + "-lc", + `source "${installerPath}" >/dev/null 2>&1 +OPENCLAW_BIN="$FAKE_OPENCLAW_BIN" +resolve_openclaw_version`, + ], + { + cwd: process.cwd(), + encoding: "utf-8", + env: { + ...process.env, + FAKE_OPENCLAW_BIN: cliPath, + OPENCLAW_INSTALL_SH_NO_RUN: "1", + }, + }, + ); + return output.trim(); +} + +function resolveVersionFromInstallerViaStdin(cliPath: string, cwd: string): string { + const installerPath = path.join(process.cwd(), "scripts", "install.sh"); + const installerSource = fs.readFileSync(installerPath, "utf-8"); + const output = execFileSync("bash", [], { + cwd, + encoding: "utf-8", + input: `${installerSource} +OPENCLAW_BIN="$FAKE_OPENCLAW_BIN" +resolve_openclaw_version +`, + env: { + ...process.env, + FAKE_OPENCLAW_BIN: cliPath, + OPENCLAW_INSTALL_SH_NO_RUN: "1", + }, + }); + return output.trim(); +} + +describe("install.sh version resolution", () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform !== "win32")( + "extracts the semantic version from decorated CLI output", + () => { + const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)"); + tempRoots.push(fixture.root); + + expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("2026.3.8"); + }, + ); + + it.runIf(process.platform !== "win32")( + "falls back to raw output when no semantic version is present", + () => { + const fixture = withFakeCli("OpenClaw dev's build"); + tempRoots.push(fixture.root); + + expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("OpenClaw dev's build"); + }, + ); + + it.runIf(process.platform !== "win32")( + "does not source version helpers from cwd when installer runs via stdin", + () => { + const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)"); + tempRoots.push(fixture.root); + + const hostileCwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-stdin-")); + tempRoots.push(hostileCwd); + const hostileHelper = path.join( + hostileCwd, + "docker", + "install-sh-common", + "version-parse.sh", + ); + fs.mkdirSync(path.dirname(hostileHelper), { recursive: true }); + fs.writeFileSync( + hostileHelper, + `#!/usr/bin/env bash +extract_openclaw_semver() { + printf '%s' 'poisoned' +} +`, + "utf-8", + ); + + expect(resolveVersionFromInstallerViaStdin(fixture.cliPath, hostileCwd)).toBe("2026.3.8"); + }, + ); +}); From b7ad8fd6613d73d1a7df076dbe04ffa585aec929 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:03:16 +0000 Subject: [PATCH 064/311] fix: fail closed talk provider selection --- .../ai/openclaw/app/voice/TalkModeManager.kt | 16 +++-- .../app/voice/TalkModeConfigParsingTest.kt | 44 ++++++++++++ .../OpenClawKit/TalkConfigParsing.swift | 17 +++-- .../TalkConfigParsingTests.swift | 30 ++++++++ src/config/config.talk-validation.test.ts | 64 +++++++++++++++++ src/config/talk.ts | 6 +- src/config/zod-schema.talk.test.ts | 32 +++++++++ src/config/zod-schema.ts | 72 ++++++++++++------- 8 files changed, 245 insertions(+), 36 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index e63d012eb0a11..98823e0b216c5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -99,10 +99,18 @@ class TalkModeManager( val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null providerId to providerConfig }?.toMap().orEmpty() - val providerId = - normalizeTalkProviderId(rawProvider) - ?: providers.keys.sorted().firstOrNull() - ?: defaultTalkProvider + val explicitProviderId = normalizeTalkProviderId(rawProvider) + if (explicitProviderId != null) { + if (providers.isNotEmpty() && providers[explicitProviderId] == null) { + return null + } + return TalkProviderConfigSelection( + provider = explicitProviderId, + config = providers[explicitProviderId] ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + val providerId = providers.keys.singleOrNull() ?: return null return TalkProviderConfigSelection( provider = providerId, config = providers[providerId] ?: buildJsonObject {}, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 9188436a18316..04fae5be2c7ed 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -68,6 +68,50 @@ class TalkModeConfigParsingTest { assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content) } + @Test + fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() { + val talk = + json.parseToJsonElement( + """ + { + "provider": "acme", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + } + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertEquals(null, selection) + } + + @Test + fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() { + val talk = + json.parseToJsonElement( + """ + { + "providers": { + "acme": { + "voiceId": "voice-acme" + }, + "elevenlabs": { + "voiceId": "voice-normalized" + } + } + } + """.trimIndent(), + ) + .jsonObject + + val selection = TalkModeManager.selectTalkProviderConfig(talk) + assertEquals(null, selection) + } + @Test fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { val legacyApiKey = "legacy-key" // pragma: allowlist secret diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift index 05c587b2e9df5..0d5ade70e4c62 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift @@ -31,10 +31,19 @@ public enum TalkConfigParsing { let hasNormalizedPayload = rawProvider != nil || rawProviders != nil if hasNormalizedPayload { let normalizedProviders = self.normalizedTalkProviders(rawProviders) - let providerID = - self.normalizedTalkProviderID(rawProvider) ?? - normalizedProviders.keys.min() ?? - defaultProvider + let explicitProviderID = self.normalizedTalkProviderID(rawProvider) + if let explicitProviderID { + if !normalizedProviders.isEmpty, normalizedProviders[explicitProviderID] == nil { + return nil + } + return TalkProviderConfigSelection( + provider: explicitProviderID, + config: normalizedProviders[explicitProviderID] ?? [:], + normalizedPayload: true) + } + guard normalizedProviders.count == 1, let providerID = normalizedProviders.keys.first else { + return nil + } return TalkProviderConfigSelection( provider: providerID, config: normalizedProviders[providerID] ?? [:], diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift index 5edd2ff336879..f710a3497cf84 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift @@ -66,6 +66,36 @@ struct TalkConfigParsingTests { #expect(selection == nil) } + @Test func rejectsNormalizedPayloadWhenProviderMissingFromProviders() { + let talk: [String: AnyCodable] = [ + "provider": AnyCodable("acme"), + "providers": AnyCodable([ + "elevenlabs": [ + "voiceId": "voice-normalized", + ], + ]), + ] + + let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") + #expect(selection == nil) + } + + @Test func rejectsNormalizedPayloadWhenMultipleProvidersAndNoProvider() { + let talk: [String: AnyCodable] = [ + "providers": AnyCodable([ + "acme": [ + "voiceId": "voice-acme", + ], + "elevenlabs": [ + "voiceId": "voice-eleven", + ], + ]), + ] + + let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") + #expect(selection == nil) + } + @Test func bridgesFoundationDictionary() { let raw: [String: Any] = [ "provider": "elevenlabs", diff --git a/src/config/config.talk-validation.test.ts b/src/config/config.talk-validation.test.ts index 8a0c93ecd3b03..cb948d75c75c6 100644 --- a/src/config/config.talk-validation.test.ts +++ b/src/config/config.talk-validation.test.ts @@ -37,4 +37,68 @@ describe("talk config validation fail-closed behavior", () => { }, ); }); + + it("rejects talk.provider when it does not match talk.providers during config load", async () => { + await withTempHomeConfig( + { + agents: { list: [{ id: "main" }] }, + talk: { + provider: "acme", + providers: { + elevenlabs: { + voiceId: "voice-123", + }, + }, + }, + }, + async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + let thrown: unknown; + try { + loadConfig(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as { code?: string } | undefined)?.code).toBe("INVALID_CONFIG"); + expect((thrown as Error).message).toMatch(/talk\.provider|talk\.providers|acme/i); + expect(consoleSpy).toHaveBeenCalled(); + }, + ); + }); + + it("rejects multi-provider talk config without talk.provider during config load", async () => { + await withTempHomeConfig( + { + agents: { list: [{ id: "main" }] }, + talk: { + providers: { + acme: { + voiceId: "voice-acme", + }, + elevenlabs: { + voiceId: "voice-eleven", + }, + }, + }, + }, + async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + let thrown: unknown; + try { + loadConfig(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as { code?: string } | undefined)?.code).toBe("INVALID_CONFIG"); + expect((thrown as Error).message).toMatch(/talk\.provider|required/i); + expect(consoleSpy).toHaveBeenCalled(); + }, + ); + }); }); diff --git a/src/config/talk.ts b/src/config/talk.ts index 2d8f4b79c3dd2..32c4255a7a4d1 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -158,10 +158,14 @@ function legacyProviderConfigFromTalk( function activeProviderFromTalk(talk: TalkConfig): string | undefined { const provider = normalizeString(talk.provider); + const providers = talk.providers; if (provider) { + if (providers && !(provider in providers)) { + return undefined; + } return provider; } - const providerIds = talk.providers ? Object.keys(talk.providers) : []; + const providerIds = providers ? Object.keys(providers) : []; return providerIds.length === 1 ? providerIds[0] : undefined; } diff --git a/src/config/zod-schema.talk.test.ts b/src/config/zod-schema.talk.test.ts index 6f1f22ebc1463..bbb7eb9f89f6e 100644 --- a/src/config/zod-schema.talk.test.ts +++ b/src/config/zod-schema.talk.test.ts @@ -25,4 +25,36 @@ describe("OpenClawSchema talk validation", () => { }), ).toThrow(/silenceTimeoutMs|number|integer/i); }); + + it("rejects talk.provider when it does not match talk.providers", () => { + expect(() => + OpenClawSchema.parse({ + talk: { + provider: "acme", + providers: { + elevenlabs: { + voiceId: "voice-123", + }, + }, + }, + }), + ).toThrow(/talk\.provider|talk\.providers|missing "acme"/i); + }); + + it("rejects multi-provider talk config without talk.provider", () => { + expect(() => + OpenClawSchema.parse({ + talk: { + providers: { + acme: { + voiceId: "voice-acme", + }, + elevenlabs: { + voiceId: "voice-eleven", + }, + }, + }, + }), + ).toThrow(/talk\.provider|required/i); + }); }); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 731909da72df1..62b7f2f1513db 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -159,6 +159,50 @@ const PluginEntrySchema = z }) .strict(); +const TalkProviderEntrySchema = z + .object({ + voiceId: z.string().optional(), + voiceAliases: z.record(z.string(), z.string()).optional(), + modelId: z.string().optional(), + outputFormat: z.string().optional(), + apiKey: SecretInputSchema.optional().register(sensitive), + }) + .catchall(z.unknown()); + +const TalkSchema = z + .object({ + provider: z.string().optional(), + providers: z.record(z.string(), TalkProviderEntrySchema).optional(), + voiceId: z.string().optional(), + voiceAliases: z.record(z.string(), z.string()).optional(), + modelId: z.string().optional(), + outputFormat: z.string().optional(), + apiKey: SecretInputSchema.optional().register(sensitive), + interruptOnSpeech: z.boolean().optional(), + silenceTimeoutMs: z.number().int().positive().optional(), + }) + .strict() + .superRefine((talk, ctx) => { + const provider = talk.provider?.trim().toLowerCase(); + const providers = talk.providers ? Object.keys(talk.providers) : []; + + if (provider && providers.length > 0 && !(provider in talk.providers!)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provider"], + message: `talk.provider must match a key in talk.providers (missing "${provider}")`, + }); + } + + if (!provider && providers.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provider"], + message: "talk.provider is required when talk.providers defines multiple providers", + }); + } + }); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -572,33 +616,7 @@ export const OpenClawSchema = z }) .strict() .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: SecretInputSchema.optional().register(sensitive), - }) - .catchall(z.unknown()), - ) - .optional(), - voiceId: z.string().optional(), - voiceAliases: z.record(z.string(), z.string()).optional(), - modelId: z.string().optional(), - outputFormat: z.string().optional(), - apiKey: SecretInputSchema.optional().register(sensitive), - interruptOnSpeech: z.boolean().optional(), - silenceTimeoutMs: z.number().int().positive().optional(), - }) - .strict() - .optional(), + talk: TalkSchema.optional(), gateway: z .object({ port: z.number().int().positive().optional(), From 87640f9a6100fe3282cdb4dbf8a1c4599d321b19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:05:44 +0000 Subject: [PATCH 065/311] fix: align talk config secret schemas --- src/gateway/protocol/index.test.ts | 40 ++++++++++++++++- src/gateway/protocol/index.ts | 1 + src/gateway/protocol/schema/channels.ts | 6 +-- src/gateway/protocol/schema/primitives.ts | 17 +++++++ src/gateway/server.talk-config.test.ts | 55 ++++++++++++++++++++++- 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index c74e7361db3a3..3554f32627834 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -1,6 +1,6 @@ import type { ErrorObject } from "ajv"; import { describe, expect, it } from "vitest"; -import { formatValidationErrors } from "./index.js"; +import { formatValidationErrors, validateTalkConfigResult } from "./index.js"; const makeError = (overrides: Partial): ErrorObject => ({ keyword: "type", @@ -62,3 +62,41 @@ describe("formatValidationErrors", () => { ); }); }); + +describe("validateTalkConfigResult", () => { + it("accepts Talk SecretRef payloads", () => { + expect( + validateTalkConfigResult({ + config: { + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }, + }, + resolved: { + provider: "elevenlabs", + config: { + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }, + }, + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }, + }, + }), + ).toBe(true); + }); +}); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 507c20025acd3..7d3e5a8cb51be 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -334,6 +334,7 @@ export const validateWizardCancelParams = ajv.compile(Wizard export const validateWizardStatusParams = ajv.compile(WizardStatusParamsSchema); export const validateTalkModeParams = ajv.compile(TalkModeParamsSchema); export const validateTalkConfigParams = ajv.compile(TalkConfigParamsSchema); +export const validateTalkConfigResult = ajv.compile(TalkConfigResultSchema); export const validateChannelsStatusParams = ajv.compile( ChannelsStatusParamsSchema, ); diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index cfe05819caa5f..f53c1f5302b6a 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import { NonEmptyString } from "./primitives.js"; +import { NonEmptyString, SecretInputSchema } from "./primitives.js"; export const TalkModeParamsSchema = Type.Object( { @@ -22,7 +22,7 @@ const TalkProviderConfigSchema = Type.Object( voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), modelId: Type.Optional(Type.String()), outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(Type.String()), + apiKey: Type.Optional(SecretInputSchema), }, { additionalProperties: true }, ); @@ -49,7 +49,7 @@ export const TalkConfigResultSchema = Type.Object( voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), modelId: Type.Optional(Type.String()), outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(Type.String()), + apiKey: Type.Optional(SecretInputSchema), interruptOnSpeech: Type.Optional(Type.Boolean()), silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts index 849778149e185..2268d1bde5040 100644 --- a/src/gateway/protocol/schema/primitives.ts +++ b/src/gateway/protocol/schema/primitives.ts @@ -20,3 +20,20 @@ export const GatewayClientIdSchema = Type.Union( export const GatewayClientModeSchema = Type.Union( Object.values(GATEWAY_CLIENT_MODES).map((value) => Type.Literal(value)), ); + +export const SecretRefSourceSchema = Type.Union([ + Type.Literal("env"), + Type.Literal("file"), + Type.Literal("exec"), +]); + +export const SecretRefSchema = Type.Object( + { + source: SecretRefSourceSchema, + provider: NonEmptyString, + id: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SecretInputSchema = Type.Union([Type.String(), SecretRefSchema]); diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 5c7fb760ed9db..f430edfc185fc 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -7,6 +7,7 @@ import { signDevicePayload, } from "../infra/device-identity.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; +import { validateTalkConfigResult } from "./protocol/index.js"; import { connectOk, installGatewayTestHooks, @@ -57,7 +58,7 @@ async function connectOperator(ws: GatewaySocket, scopes: string[]) { } async function writeTalkConfig(config: { - apiKey?: string; + apiKey?: string | { source: "env" | "file" | "exec"; provider: string; id: string }; voiceId?: string; silenceTimeoutMs?: number; }) { @@ -140,6 +141,58 @@ describe("gateway talk.config", () => { }); }); + it("returns Talk SecretRef payloads that satisfy the protocol schema", async () => { + await writeTalkConfig({ + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }); + + await withServer(async (ws) => { + await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]); + const res = await rpcReq<{ + config?: { + talk?: { + apiKey?: { source?: string; provider?: string; id?: string }; + providers?: { + elevenlabs?: { + apiKey?: { source?: string; provider?: string; id?: string }; + }; + }; + resolved?: { + provider?: string; + config?: { + apiKey?: { source?: string; provider?: string; id?: string }; + }; + }; + }; + }; + }>(ws, "talk.config", { + includeSecrets: true, + }); + expect(res.ok).toBe(true); + expect(validateTalkConfigResult(res.payload)).toBe(true); + expect(res.payload?.config?.talk?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }); + expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }); + }); + }); + it("prefers normalized provider payload over conflicting legacy talk keys", async () => { const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ From 8d3d742c6a2f641c103c0c2b7705224b6e902802 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:08:22 +0000 Subject: [PATCH 066/311] refactor: require canonical talk resolved payload --- .../ai/openclaw/app/voice/TalkModeManager.kt | 27 +++------------ .../app/voice/TalkModeConfigParsingTest.kt | 5 +-- apps/ios/Sources/Voice/TalkModeManager.swift | 2 +- .../Tests/Logic/TalkConfigParsingTests.swift | 5 ++- .../Sources/OpenClaw/TalkModeRuntime.swift | 5 ++- .../TalkModeConfigParsingTests.swift | 6 ++-- .../OpenClawKit/TalkConfigParsing.swift | 34 ++----------------- .../TalkConfigParsingTests.swift | 6 ++-- 8 files changed, 18 insertions(+), 72 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 98823e0b216c5..3bd52a3a0a094 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -93,29 +93,7 @@ class TalkModeManager( 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 explicitProviderId = normalizeTalkProviderId(rawProvider) - if (explicitProviderId != null) { - if (providers.isNotEmpty() && providers[explicitProviderId] == null) { - return null - } - return TalkProviderConfigSelection( - provider = explicitProviderId, - config = providers[explicitProviderId] ?: buildJsonObject {}, - normalizedPayload = true, - ) - } - val providerId = providers.keys.singleOrNull() ?: return null - return TalkProviderConfigSelection( - provider = providerId, - config = providers[providerId] ?: buildJsonObject {}, - normalizedPayload = true, - ) + return null } return TalkProviderConfigSelection( provider = defaultTalkProvider, @@ -1425,6 +1403,9 @@ class TalkModeManager( val config = root?.get("config").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull() val selection = selectTalkProviderConfig(talk) + if (talk != null && selection == null) { + Log.w(tag, "talk config ignored: normalized payload missing talk.resolved") + } val activeProvider = selection?.provider ?: defaultTalkProvider val activeConfig = selection?.config val sessionCfg = config?.get("session").asObjectOrNull() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 04fae5be2c7ed..4ccee1cdf69c7 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -62,10 +62,7 @@ class TalkModeConfigParsingTest { .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) + assertEquals(null, selection) } @Test diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index ad972af7f4518..9e963c17a167e 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -1996,7 +1996,7 @@ extension TalkModeManager { 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") + "talk config ignored: normalized payload missing talk.resolved") } let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeConfig = selection?.config diff --git a/apps/ios/Tests/Logic/TalkConfigParsingTests.swift b/apps/ios/Tests/Logic/TalkConfigParsingTests.swift index 6c99b85dea2ba..c7fb9b0e2094c 100644 --- a/apps/ios/Tests/Logic/TalkConfigParsingTests.swift +++ b/apps/ios/Tests/Logic/TalkConfigParsingTests.swift @@ -5,7 +5,7 @@ import Testing private let iOSSilenceTimeoutMs = 900 @Suite struct TalkConfigParsingTests { - @Test func prefersNormalizedTalkProviderPayload() { + @Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() { let talk: [String: Any] = [ "provider": "elevenlabs", "providers": [ @@ -20,8 +20,7 @@ private let iOSSilenceTimeoutMs = 900 TalkConfigParsing.bridgeFoundationDictionary(talk), defaultProvider: "elevenlabs", allowLegacyFallback: false) - #expect(selection?.provider == "elevenlabs") - #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + #expect(selection == nil) } @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index ad5d7e3a886bd..8013135b330ab 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -831,6 +831,9 @@ extension TalkModeRuntime { timeoutMs: 8000) let talk = snap.config?["talk"]?.dictionaryValue let selection = Self.selectTalkProviderConfig(talk) + if talk != nil, selection == nil { + self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved") + } let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeConfig = selection?.config let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) @@ -870,7 +873,7 @@ extension TalkModeRuntime { self.ttsLogger .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") } else if selection?.normalizedPayload == true { - self.ttsLogger.info("talk config provider elevenlabs") + self.ttsLogger.info("talk config provider from talk.resolved") } return TalkRuntimeConfig( voiceId: resolvedVoice, diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift index 15797cebf9e1f..9409e110689ec 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeConfigParsingTests.swift @@ -3,7 +3,7 @@ import Testing @testable import OpenClaw struct TalkModeConfigParsingTests { - @Test func `prefers normalized talk provider payload`() { + @Test func `rejects normalized talk provider payload without resolved`() { let talk: [String: AnyCodable] = [ "provider": AnyCodable("elevenlabs"), "providers": AnyCodable([ @@ -15,9 +15,7 @@ struct TalkModeConfigParsingTests { ] let selection = TalkModeRuntime.selectTalkProviderConfig(talk) - #expect(selection?.provider == "elevenlabs") - #expect(selection?.normalizedPayload == true) - #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + #expect(selection == nil) } @Test func `falls back to legacy talk fields when normalized payload missing`() { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift index 0d5ade70e4c62..6bdd6b9f244d7 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkConfigParsing.swift @@ -26,28 +26,9 @@ public enum TalkConfigParsing { if let resolvedSelection = self.resolvedProviderConfig(talk) { return resolvedSelection } - let rawProvider = talk["provider"]?.stringValue - let rawProviders = talk["providers"] - let hasNormalizedPayload = rawProvider != nil || rawProviders != nil + let hasNormalizedPayload = talk["provider"] != nil || talk["providers"] != nil if hasNormalizedPayload { - let normalizedProviders = self.normalizedTalkProviders(rawProviders) - let explicitProviderID = self.normalizedTalkProviderID(rawProvider) - if let explicitProviderID { - if !normalizedProviders.isEmpty, normalizedProviders[explicitProviderID] == nil { - return nil - } - return TalkProviderConfigSelection( - provider: explicitProviderID, - config: normalizedProviders[explicitProviderID] ?? [:], - normalizedPayload: true) - } - guard normalizedProviders.count == 1, let providerID = normalizedProviders.keys.first else { - return nil - } - return TalkProviderConfigSelection( - provider: providerID, - config: normalizedProviders[providerID] ?? [:], - normalizedPayload: true) + return nil } guard allowLegacyFallback else { return nil } return TalkProviderConfigSelection( @@ -92,15 +73,4 @@ public enum TalkConfigParsing { config: resolved["config"]?.dictionaryValue ?? [:], normalizedPayload: true) } - - private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] { - guard let providerMap = raw?.dictionaryValue else { return [:] } - return providerMap.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 - } - } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift index f710a3497cf84..5a8d5dd11d385 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigParsingTests.swift @@ -24,7 +24,7 @@ struct TalkConfigParsingTests { #expect(selection?.config["voiceId"]?.stringValue == "voice-resolved") } - @Test func prefersNormalizedTalkProviderPayload() { + @Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() { let talk: [String: AnyCodable] = [ "provider": AnyCodable("elevenlabs"), "providers": AnyCodable([ @@ -36,9 +36,7 @@ struct TalkConfigParsingTests { ] let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") - #expect(selection?.provider == "elevenlabs") - #expect(selection?.normalizedPayload == true) - #expect(selection?.config["voiceId"]?.stringValue == "voice-normalized") + #expect(selection == nil) } @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { From dc5645d45997ad51e1cc69c60f365e7f9c5cedc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:11:03 +0000 Subject: [PATCH 067/311] test: add talk config contract fixtures --- .../app/voice/TalkModeConfigContractTest.kt | 76 ++++++++++++++++ .../TalkConfigContractTests.swift | 60 +++++++++++++ .../protocol/talk-config.contract.test.ts | 57 ++++++++++++ test-fixtures/talk-config-contract.json | 88 +++++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift create mode 100644 src/gateway/protocol/talk-config.contract.test.ts create mode 100644 test-fixtures/talk-config-contract.json diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt new file mode 100644 index 0000000000000..16336d6570640 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt @@ -0,0 +1,76 @@ +package ai.openclaw.app.voice + +import java.io.File +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +@Serializable +private data class TalkConfigContractFixture( + @SerialName("selectionCases") val selectionCases: List, +) { + @Serializable + data class SelectionCase( + val id: String, + val defaultProvider: String, + val payloadValid: Boolean, + val expectedSelection: ExpectedSelection? = null, + val talk: JsonObject, + ) + + @Serializable + data class ExpectedSelection( + val provider: String, + val normalizedPayload: Boolean, + val voiceId: String? = null, + ) +} + +class TalkModeConfigContractTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun selectionFixtures() { + for (fixture in loadFixtures().selectionCases) { + val selection = TalkModeManager.selectTalkProviderConfig(fixture.talk) + val expected = fixture.expectedSelection + if (expected == null) { + assertNull(fixture.id, selection) + continue + } + assertNotNull(fixture.id, selection) + assertEquals(fixture.id, expected.provider, selection?.provider) + assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload) + assertEquals( + fixture.id, + expected.voiceId, + (selection?.config?.get("voiceId") as? JsonPrimitive)?.content, + ) + assertEquals(fixture.id, true, fixture.payloadValid) + } + } + + private fun loadFixtures(): TalkConfigContractFixture { + val fixturePath = findFixtureFile() + return json.decodeFromString(File(fixturePath).readText()) + } + + private fun findFixtureFile(): String { + val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable") + var current = File(startDir).absoluteFile + while (true) { + val candidate = File(current, "test-fixtures/talk-config-contract.json") + if (candidate.exists()) { + return candidate.absolutePath + } + current = current.parentFile ?: break + } + error("talk-config-contract.json not found from $startDir") + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift new file mode 100644 index 0000000000000..3ca2031e03e3f --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift @@ -0,0 +1,60 @@ +import Foundation +import OpenClawKit +import Testing + +private struct TalkConfigContractFixture: Decodable { + let selectionCases: [SelectionCase] + + struct SelectionCase: Decodable { + let id: String + let defaultProvider: String + let payloadValid: Bool + let expectedSelection: ExpectedSelection? + let talk: [String: AnyCodable] + } + + struct ExpectedSelection: Decodable { + let provider: String + let normalizedPayload: Bool + let voiceId: String? + } +} + +private enum TalkConfigContractFixtureLoader { + static func load() throws -> TalkConfigContractFixture { + let fixtureURL = try self.findFixtureURL(startingAt: URL(fileURLWithPath: #filePath)) + let data = try Data(contentsOf: fixtureURL) + return try JSONDecoder().decode(TalkConfigContractFixture.self, from: data) + } + + private static func findFixtureURL(startingAt fileURL: URL) throws -> URL { + var directory = fileURL.deletingLastPathComponent() + while directory.path != "/" { + let candidate = directory.appendingPathComponent("test-fixtures/talk-config-contract.json") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + directory.deleteLastPathComponent() + } + throw NSError(domain: "TalkConfigContractFixtureLoader", code: 1) + } +} + +struct TalkConfigContractTests { + @Test func selectionFixtures() throws { + for fixture in try TalkConfigContractFixtureLoader.load().selectionCases { + let selection = TalkConfigParsing.selectProviderConfig( + fixture.talk, + defaultProvider: fixture.defaultProvider) + if let expected = fixture.expectedSelection { + #expect(selection != nil) + #expect(selection?.provider == expected.provider) + #expect(selection?.normalizedPayload == expected.normalizedPayload) + #expect(selection?.config["voiceId"]?.stringValue == expected.voiceId) + } else { + #expect(selection == nil) + } + #expect(fixture.payloadValid == (selection != nil)) + } + } +} diff --git a/src/gateway/protocol/talk-config.contract.test.ts b/src/gateway/protocol/talk-config.contract.test.ts new file mode 100644 index 0000000000000..59cac413a904f --- /dev/null +++ b/src/gateway/protocol/talk-config.contract.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { validateTalkConfigResult } from "./index.js"; + +type ExpectedSelection = { + provider: string; + normalizedPayload: boolean; + voiceId?: string; +}; + +type SelectionContractCase = { + id: string; + defaultProvider: string; + payloadValid: boolean; + expectedSelection: ExpectedSelection | null; + talk: Record; +}; + +type TalkConfigContractFixture = { + selectionCases: SelectionContractCase[]; +}; + +const fixturePath = new URL("../../../test-fixtures/talk-config-contract.json", import.meta.url); +const fixtures = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as TalkConfigContractFixture; + +describe("talk.config contract fixtures", () => { + for (const fixture of fixtures.selectionCases) { + it(fixture.id, () => { + const payload = { config: { talk: fixture.talk } }; + if (fixture.payloadValid) { + expect(validateTalkConfigResult(payload)).toBe(true); + } else { + expect((payload.config.talk as { resolved?: unknown }).resolved).toBeUndefined(); + } + + if (!fixture.expectedSelection) { + return; + } + + const talk = payload.config.talk as { + resolved?: { + provider?: string; + config?: { + voiceId?: string; + }; + }; + voiceId?: string; + }; + expect(talk.resolved?.provider ?? fixture.defaultProvider).toBe( + fixture.expectedSelection.provider, + ); + expect(talk.resolved?.config?.voiceId ?? talk.voiceId).toBe( + fixture.expectedSelection.voiceId, + ); + }); + } +}); diff --git a/test-fixtures/talk-config-contract.json b/test-fixtures/talk-config-contract.json new file mode 100644 index 0000000000000..8877c189385a1 --- /dev/null +++ b/test-fixtures/talk-config-contract.json @@ -0,0 +1,88 @@ +{ + "selectionCases": [ + { + "id": "canonical_resolved_wins", + "defaultProvider": "elevenlabs", + "payloadValid": true, + "expectedSelection": { + "provider": "elevenlabs", + "normalizedPayload": true, + "voiceId": "voice-resolved" + }, + "talk": { + "resolved": { + "provider": "elevenlabs", + "config": { + "voiceId": "voice-resolved" + } + }, + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + }, + "voiceId": "voice-legacy" + } + }, + { + "id": "normalized_missing_resolved", + "defaultProvider": "elevenlabs", + "payloadValid": false, + "expectedSelection": null, + "talk": { + "provider": "elevenlabs", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + }, + "voiceId": "voice-legacy" + } + }, + { + "id": "provider_mismatch_missing_resolved", + "defaultProvider": "elevenlabs", + "payloadValid": false, + "expectedSelection": null, + "talk": { + "provider": "acme", + "providers": { + "elevenlabs": { + "voiceId": "voice-normalized" + } + } + } + }, + { + "id": "ambiguous_providers_missing_resolved", + "defaultProvider": "elevenlabs", + "payloadValid": false, + "expectedSelection": null, + "talk": { + "providers": { + "acme": { + "voiceId": "voice-acme" + }, + "elevenlabs": { + "voiceId": "voice-normalized" + } + } + } + }, + { + "id": "legacy_payload_fallback", + "defaultProvider": "elevenlabs", + "payloadValid": true, + "expectedSelection": { + "provider": "elevenlabs", + "normalizedPayload": false, + "voiceId": "voice-legacy" + }, + "talk": { + "voiceId": "voice-legacy", + "apiKey": "legacy-key" + } + } + ] +} From 16a5f0b006919f9b6150332474e8ceca08320513 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:15:46 +0000 Subject: [PATCH 068/311] refactor: split talk gateway config loaders --- .../app/voice/TalkModeGatewayConfig.kt | 110 ++++++++++++++++++ .../ai/openclaw/app/voice/TalkModeManager.kt | 81 ++++++------- .../Sources/Voice/TalkModeGatewayConfig.swift | 69 +++++++++++ apps/ios/Sources/Voice/TalkModeManager.swift | 58 +++------ .../OpenClaw/TalkModeGatewayConfig.swift | 104 +++++++++++++++++ .../Sources/OpenClaw/TalkModeRuntime.swift | 91 ++++----------- 6 files changed, 359 insertions(+), 154 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt create mode 100644 apps/ios/Sources/Voice/TalkModeGatewayConfig.swift create mode 100644 apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt new file mode 100644 index 0000000000000..4293c11389664 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt @@ -0,0 +1,110 @@ +package ai.openclaw.app.voice + +import ai.openclaw.app.normalizeMainKey +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +internal data class TalkModeGatewayConfigState( + val activeProvider: String, + val normalizedPayload: Boolean, + val missingResolvedPayload: Boolean, + val mainSessionKey: String, + val defaultVoiceId: String?, + val voiceAliases: Map, + val defaultModelId: String, + val defaultOutputFormat: String, + val apiKey: String?, + val interruptOnSpeech: Boolean?, + val silenceTimeoutMs: Long, +) + +internal object TalkModeGatewayConfigParser { + fun parse( + config: JsonObject?, + defaultProvider: String, + defaultModelIdFallback: String, + defaultOutputFormatFallback: String, + envVoice: String?, + sagVoice: String?, + envKey: String?, + ): TalkModeGatewayConfigState { + val talk = config?.get("talk").asObjectOrNull() + val selection = TalkModeManager.selectTalkProviderConfig(talk) + val activeProvider = selection?.provider ?: defaultProvider + val activeConfig = selection?.config + val sessionCfg = config?.get("session").asObjectOrNull() + val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) + val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } + val aliases = + activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) -> + val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id } + }?.toMap().orEmpty() + 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() + val silenceTimeoutMs = TalkModeManager.resolvedSilenceTimeoutMs(talk) + + return TalkModeGatewayConfigState( + activeProvider = activeProvider, + normalizedPayload = selection?.normalizedPayload == true, + missingResolvedPayload = talk != null && selection == null, + mainSessionKey = mainKey, + defaultVoiceId = + if (activeProvider == defaultProvider) { + voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } + } else { + voice + }, + voiceAliases = aliases, + defaultModelId = model ?: defaultModelIdFallback, + defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback, + apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }, + interruptOnSpeech = interrupt, + silenceTimeoutMs = silenceTimeoutMs, + ) + } + + fun fallback( + defaultProvider: String, + defaultModelIdFallback: String, + defaultOutputFormatFallback: String, + envVoice: String?, + sagVoice: String?, + envKey: String?, + ): TalkModeGatewayConfigState = + TalkModeGatewayConfigState( + activeProvider = defaultProvider, + normalizedPayload = false, + missingResolvedPayload = false, + mainSessionKey = "main", + defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }, + voiceAliases = emptyMap(), + defaultModelId = defaultModelIdFallback, + defaultOutputFormat = defaultOutputFormatFallback, + apiKey = envKey?.takeIf { it.isNotEmpty() }, + interruptOnSpeech = null, + silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs, + ) +} + +private fun normalizeTalkAliasKey(value: String): String = + value.trim().lowercase() + +private fun JsonElement?.asStringOrNull(): String? = + this?.let { element -> + element as? JsonPrimitive + }?.contentOrNull + +private fun JsonElement?.asBooleanOrNull(): Boolean? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.booleanOrNull +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = + this as? JsonObject diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 3bd52a3a0a094..8a3b6fd948d88 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -1400,69 +1400,64 @@ class TalkModeManager( try { val res = session.request("talk.config", """{"includeSecrets":true}""") val root = json.parseToJsonElement(res).asObjectOrNull() - val config = root?.get("config").asObjectOrNull() - val talk = config?.get("talk").asObjectOrNull() - val selection = selectTalkProviderConfig(talk) - if (talk != null && selection == null) { + val parsed = + TalkModeGatewayConfigParser.parse( + config = root?.get("config").asObjectOrNull(), + defaultProvider = defaultTalkProvider, + defaultModelIdFallback = defaultModelIdFallback, + defaultOutputFormatFallback = defaultOutputFormatFallback, + envVoice = envVoice, + sagVoice = sagVoice, + envKey = envKey, + ) + if (parsed.missingResolvedPayload) { Log.w(tag, "talk config ignored: normalized payload missing talk.resolved") } - val activeProvider = selection?.provider ?: defaultTalkProvider - val activeConfig = selection?.config - val sessionCfg = config?.get("session").asObjectOrNull() - val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()) - val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } - val aliases = - 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 = 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() - val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk) if (!isCanonicalMainSessionKey(mainSessionKey)) { - mainSessionKey = mainKey + mainSessionKey = parsed.mainSessionKey } - defaultVoiceId = - if (activeProvider == defaultTalkProvider) { - voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } - } else { - voice - } - voiceAliases = aliases + defaultVoiceId = parsed.defaultVoiceId + voiceAliases = parsed.voiceAliases if (!voiceOverrideActive) currentVoiceId = defaultVoiceId - defaultModelId = model ?: defaultModelIdFallback + defaultModelId = parsed.defaultModelId if (!modelOverrideActive) currentModelId = defaultModelId - defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback - apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } - silenceWindowMs = silenceTimeoutMs + defaultOutputFormat = parsed.defaultOutputFormat + apiKey = parsed.apiKey + silenceWindowMs = parsed.silenceTimeoutMs Log.d( tag, - "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=$silenceTimeoutMs", + "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=${parsed.silenceTimeoutMs}", ) - if (interrupt != null) interruptOnSpeech = interrupt - activeProviderIsElevenLabs = activeProvider == defaultTalkProvider + if (parsed.interruptOnSpeech != null) interruptOnSpeech = parsed.interruptOnSpeech + activeProviderIsElevenLabs = parsed.activeProvider == defaultTalkProvider if (!activeProviderIsElevenLabs) { // Clear ElevenLabs credentials so playAssistant won't attempt ElevenLabs calls apiKey = null defaultVoiceId = null if (!voiceOverrideActive) currentVoiceId = null - Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback") - } else if (selection?.normalizedPayload == true) { + Log.w(tag, "talk provider ${parsed.activeProvider} unsupported; using system voice fallback") + } else if (parsed.normalizedPayload) { Log.d(tag, "talk config provider=elevenlabs") } configLoaded = true } catch (_: Throwable) { - silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs - defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } - defaultModelId = defaultModelIdFallback + val fallback = + TalkModeGatewayConfigParser.fallback( + defaultProvider = defaultTalkProvider, + defaultModelIdFallback = defaultModelIdFallback, + defaultOutputFormatFallback = defaultOutputFormatFallback, + envVoice = envVoice, + sagVoice = sagVoice, + envKey = envKey, + ) + silenceWindowMs = fallback.silenceTimeoutMs + defaultVoiceId = fallback.defaultVoiceId + defaultModelId = fallback.defaultModelId if (!modelOverrideActive) currentModelId = defaultModelId - apiKey = envKey?.takeIf { it.isNotEmpty() } - voiceAliases = emptyMap() - defaultOutputFormat = defaultOutputFormatFallback + apiKey = fallback.apiKey + voiceAliases = fallback.voiceAliases + defaultOutputFormat = fallback.defaultOutputFormat // Keep config load retryable after transient fetch failures. configLoaded = false } diff --git a/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift b/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift new file mode 100644 index 0000000000000..7215bc7d1aff0 --- /dev/null +++ b/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift @@ -0,0 +1,69 @@ +import Foundation +import OpenClawKit + +struct TalkModeGatewayConfigState { + let activeProvider: String + let normalizedPayload: Bool + let missingResolvedPayload: Bool + let defaultVoiceId: String? + let voiceAliases: [String: String] + let defaultModelId: String + let defaultOutputFormat: String? + let rawConfigApiKey: String? + let interruptOnSpeech: Bool? + let silenceTimeoutMs: Int +} + +enum TalkModeGatewayConfigParser { + static func parse( + config: [String: Any], + defaultProvider: String, + defaultModelIdFallback: String, + defaultSilenceTimeoutMs: Int + ) -> TalkModeGatewayConfigState { + let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any]) + let selection = TalkConfigParsing.selectProviderConfig( + talk, + defaultProvider: defaultProvider, + allowLegacyFallback: false) + let activeProvider = selection?.provider ?? defaultProvider + let activeConfig = selection?.config + let defaultVoiceId = activeConfig?["voiceId"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) + let voiceAliases: [String: String] + if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue { + var resolved: [String: String] = [:] + for (key, value) in aliases { + guard let id = value.stringValue else { continue } + let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue } + resolved[normalizedKey] = trimmedId + } + voiceAliases = resolved + } else { + voiceAliases = [:] + } + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let defaultModelId = (model?.isEmpty == false) ? model! : defaultModelIdFallback + let defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let interruptOnSpeech = talk?["interruptOnSpeech"]?.boolValue + let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs( + talk, + fallback: defaultSilenceTimeoutMs) + + return TalkModeGatewayConfigState( + activeProvider: activeProvider, + normalizedPayload: selection?.normalizedPayload == true, + missingResolvedPayload: talk != nil && selection == nil, + defaultVoiceId: defaultVoiceId, + voiceAliases: voiceAliases, + defaultModelId: defaultModelId, + defaultOutputFormat: defaultOutputFormat, + rawConfigApiKey: rawConfigApiKey, + interruptOnSpeech: interruptOnSpeech, + silenceTimeoutMs: silenceTimeoutMs) + } +} diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 9e963c17a167e..fd3a65ca56275 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -1970,17 +1970,6 @@ extension TalkModeManager { return trimmed } - static func selectTalkProviderConfig(_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? { - TalkConfigParsing.selectProviderConfig( - talk, - defaultProvider: Self.defaultTalkProvider, - allowLegacyFallback: false) - } - - static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int { - TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: Self.defaultSilenceTimeoutMs) - } - func reloadConfig() async { guard let gateway else { return } self.pcmFormatUnavailable = false @@ -1992,41 +1981,27 @@ 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 = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any]) - let selection = Self.selectTalkProviderConfig(talk) - if talk != nil, selection == nil { + let parsed = TalkModeGatewayConfigParser.parse( + config: config, + defaultProvider: Self.defaultTalkProvider, + defaultModelIdFallback: Self.defaultModelIdFallback, + defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs) + if parsed.missingResolvedPayload { GatewayDiagnostics.log( "talk config ignored: normalized payload missing talk.resolved") } - let activeProvider = selection?.provider ?? Self.defaultTalkProvider - let activeConfig = selection?.config - let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) - self.defaultVoiceId = activeConfig?["voiceId"]?.stringValue? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue { - var resolved: [String: String] = [:] - for (key, value) in aliases { - guard let id = value.stringValue else { continue } - let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue } - resolved[normalizedKey] = trimmedId - } - self.voiceAliases = resolved - } else { - self.voiceAliases = [:] - } + let activeProvider = parsed.activeProvider + self.defaultVoiceId = parsed.defaultVoiceId + self.voiceAliases = parsed.voiceAliases if !self.voiceOverrideActive { self.currentVoiceId = self.defaultVoiceId } - let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback + self.defaultModelId = parsed.defaultModelId if !self.modelOverrideActive { self.currentModelId = self.defaultModelId } - self.defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue? - .trimmingCharacters(in: .whitespacesAndNewlines) - let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + self.defaultOutputFormat = parsed.defaultOutputFormat + let rawConfigApiKey = parsed.rawConfigApiKey let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) let localApiKey = Self.normalizedTalkApiKey( GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider)) @@ -2045,12 +2020,13 @@ extension TalkModeManager { self.gatewayTalkDefaultModelId = self.defaultModelId self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) self.gatewayTalkConfigLoaded = true - if let interrupt = talk?["interruptOnSpeech"]?.boolValue { + if let interrupt = parsed.interruptOnSpeech { self.interruptOnSpeech = interrupt } - self.silenceWindow = TimeInterval(silenceTimeoutMs) / 1000 - if selection != nil { - GatewayDiagnostics.log("talk config provider=\(activeProvider) silenceTimeoutMs=\(silenceTimeoutMs)") + self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000 + if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil { + GatewayDiagnostics.log( + "talk config provider=\(activeProvider) silenceTimeoutMs=\(parsed.silenceTimeoutMs)") } } catch { self.defaultModelId = Self.defaultModelIdFallback diff --git a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift new file mode 100644 index 0000000000000..15600b5ea0e74 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift @@ -0,0 +1,104 @@ +import Foundation +import OpenClawKit + +struct TalkModeGatewayConfigState { + let activeProvider: String + let normalizedPayload: Bool + let missingResolvedPayload: Bool + let voiceId: String? + let voiceAliases: [String: String] + let modelId: String? + let outputFormat: String? + let interruptOnSpeech: Bool + let silenceTimeoutMs: Int + let apiKey: String? + let seamColorHex: String? +} + +enum TalkModeGatewayConfigParser { + static func parse( + snapshot: ConfigSnapshot, + defaultProvider: String, + defaultModelIdFallback: String, + defaultSilenceTimeoutMs: Int, + envVoice: String?, + sagVoice: String?, + envApiKey: String? + ) -> TalkModeGatewayConfigState { + let talk = snapshot.config?["talk"]?.dictionaryValue + let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider) + let activeProvider = selection?.provider ?? defaultProvider + let activeConfig = selection?.config + let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs( + talk, + fallback: defaultSilenceTimeoutMs) + let ui = snapshot.config?["ui"]?.dictionaryValue + let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + 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() + let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !key.isEmpty, !value.isEmpty else { return } + acc[key] = value + } ?? [:] + let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedModel = (model?.isEmpty == false) ? model! : defaultModelIdFallback + let outputFormat = activeConfig?["outputFormat"]?.stringValue + let interrupt = talk?["interruptOnSpeech"]?.boolValue + let apiKey = activeConfig?["apiKey"]?.stringValue + let resolvedVoice: String? = if activeProvider == defaultProvider { + (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : 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 == defaultProvider { + (envApiKey?.isEmpty == false ? envApiKey : nil) ?? + (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) + } else { + nil + } + + return TalkModeGatewayConfigState( + activeProvider: activeProvider, + normalizedPayload: selection?.normalizedPayload == true, + missingResolvedPayload: talk != nil && selection == nil, + voiceId: resolvedVoice, + voiceAliases: resolvedAliases, + modelId: resolvedModel, + outputFormat: outputFormat, + interruptOnSpeech: interrupt ?? true, + silenceTimeoutMs: silenceTimeoutMs, + apiKey: resolvedApiKey, + seamColorHex: rawSeam.isEmpty ? nil : rawSeam) + } + + static func fallback( + defaultModelIdFallback: String, + defaultSilenceTimeoutMs: Int, + envVoice: String?, + sagVoice: String?, + envApiKey: String? + ) -> TalkModeGatewayConfigState { + let resolvedVoice = + (envVoice?.isEmpty == false ? envVoice : nil) ?? + (sagVoice?.isEmpty == false ? sagVoice : nil) + let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil + + return TalkModeGatewayConfigState( + activeProvider: "elevenlabs", + normalizedPayload: false, + missingResolvedPayload: false, + voiceId: resolvedVoice, + voiceAliases: [:], + modelId: defaultModelIdFallback, + outputFormat: nil, + interruptOnSpeech: true, + silenceTimeoutMs: defaultSilenceTimeoutMs, + apiKey: resolvedApiKey, + seamColorHex: nil) + } +} diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 8013135b330ab..1565c8a8152fe 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -798,16 +798,6 @@ extension TalkModeRuntime { "silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public)") } - private struct TalkRuntimeConfig { - let voiceId: String? - let voiceAliases: [String: String] - let modelId: String? - let outputFormat: String? - let interruptOnSpeech: Bool - let silenceTimeoutMs: Int - let apiKey: String? - } - static func selectTalkProviderConfig( _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? { @@ -818,7 +808,7 @@ extension TalkModeRuntime { TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: self.defaultSilenceTimeoutMs) } - private func fetchTalkConfig() async -> TalkRuntimeConfig { + private func fetchTalkConfig() async -> TalkModeGatewayConfigState { let env = ProcessInfo.processInfo.environment let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -829,73 +819,34 @@ extension TalkModeRuntime { method: .talkConfig, params: ["includeSecrets": AnyCodable(true)], timeoutMs: 8000) - let talk = snap.config?["talk"]?.dictionaryValue - let selection = Self.selectTalkProviderConfig(talk) - if talk != nil, selection == nil { + let parsed = TalkModeGatewayConfigParser.parse( + snapshot: snap, + defaultProvider: Self.defaultTalkProvider, + defaultModelIdFallback: Self.defaultModelIdFallback, + defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs, + envVoice: envVoice, + sagVoice: sagVoice, + envApiKey: envApiKey) + if parsed.missingResolvedPayload { self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved") } - let activeProvider = selection?.provider ?? Self.defaultTalkProvider - let activeConfig = selection?.config - let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) - let ui = snap.config?["ui"]?.dictionaryValue - let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" await MainActor.run { - AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam + AppStateStore.shared.seamColorHex = parsed.seamColorHex } - 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() - let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !key.isEmpty, !value.isEmpty else { return } - acc[key] = value - } ?? [:] - let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback - let outputFormat = activeConfig?["outputFormat"]?.stringValue - let interrupt = talk?["interruptOnSpeech"]?.boolValue - 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) - } 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 { + if parsed.activeProvider != Self.defaultTalkProvider { self.ttsLogger - .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") - } else if selection?.normalizedPayload == true { + .info("talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice") + } else if parsed.normalizedPayload { self.ttsLogger.info("talk config provider from talk.resolved") } - return TalkRuntimeConfig( - voiceId: resolvedVoice, - voiceAliases: resolvedAliases, - modelId: resolvedModel, - outputFormat: outputFormat, - interruptOnSpeech: interrupt ?? true, - silenceTimeoutMs: silenceTimeoutMs, - apiKey: resolvedApiKey) + return parsed } catch { - let resolvedVoice = - (envVoice?.isEmpty == false ? envVoice : nil) ?? - (sagVoice?.isEmpty == false ? sagVoice : nil) - let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil - return TalkRuntimeConfig( - voiceId: resolvedVoice, - voiceAliases: [:], - modelId: Self.defaultModelIdFallback, - outputFormat: nil, - interruptOnSpeech: true, - silenceTimeoutMs: Self.defaultSilenceTimeoutMs, - apiKey: resolvedApiKey) + return TalkModeGatewayConfigParser.fallback( + defaultModelIdFallback: Self.defaultModelIdFallback, + defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs, + envVoice: envVoice, + sagVoice: sagVoice, + envApiKey: envApiKey) } } From 032778fb2e3a0292997c908d7c24a805ce4d6778 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:02:24 +0000 Subject: [PATCH 069/311] refactor: avoid checkout during prep head verification --- scripts/pr | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/scripts/pr b/scripts/pr index 8e20eb7209321..4d919ccb24143 100755 --- a/scripts/pr +++ b/scripts/pr @@ -220,29 +220,39 @@ checkout_prep_branch() { # shellcheck disable=SC1091 source .local/prep-context.env + local prep_branch + prep_branch=$(resolve_prep_branch_name "$pr") + git checkout "$prep_branch" +} + +resolve_prep_branch_name() { + local pr="$1" + require_artifact .local/prep-context.env + # shellcheck disable=SC1091 + source .local/prep-context.env + local prep_branch="${PREP_BRANCH:-pr-$pr-prep}" if ! git show-ref --verify --quiet "refs/heads/$prep_branch"; then echo "Expected prep branch $prep_branch not found. Run prepare-init first." exit 1 fi - git checkout "$prep_branch" + printf '%s\n' "$prep_branch" } verify_prep_branch_matches_prepared_head() { local pr="$1" local prepared_head_sha="$2" - require_artifact .local/prep-context.env - checkout_prep_branch "$pr" - + local prep_branch + prep_branch=$(resolve_prep_branch_name "$pr") local prep_branch_head_sha - prep_branch_head_sha=$(git rev-parse HEAD) + prep_branch_head_sha=$(git rev-parse "refs/heads/$prep_branch") if [ "$prep_branch_head_sha" = "$prepared_head_sha" ]; then return 0 fi - echo "Local prep branch moved after prepare-push (expected $prepared_head_sha, got $prep_branch_head_sha)." + echo "Local prep branch moved after prepare-push (branch=$prep_branch expected $prepared_head_sha, got $prep_branch_head_sha)." if git merge-base --is-ancestor "$prepared_head_sha" "$prep_branch_head_sha" 2>/dev/null; then echo "Unpushed local commits on prep branch:" git log --oneline "${prepared_head_sha}..${prep_branch_head_sha}" | sed 's/^/ /' || true From fa00b1d0ca2650b4ec7a956728774ebc3a8f48ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:03:56 +0000 Subject: [PATCH 070/311] refactor: dedupe prep branch push flow --- scripts/pr | 377 ++++++++++++++++++++++++----------------------------- 1 file changed, 167 insertions(+), 210 deletions(-) diff --git a/scripts/pr b/scripts/pr index 4d919ccb24143..709cdb35f50d6 100755 --- a/scripts/pr +++ b/scripts/pr @@ -423,6 +423,163 @@ resolve_head_push_url_https() { return 1 } +verify_pr_head_branch_matches_expected() { + local pr="$1" + local expected_head="$2" + + local current_head + current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName) + if [ "$current_head" != "$expected_head" ]; then + echo "PR head branch changed from $expected_head to $current_head. Re-run prepare-init." + exit 1 + fi +} + +setup_prhead_remote() { + local push_url + push_url=$(resolve_head_push_url) || { + echo "Unable to resolve PR head repo push URL." + exit 1 + } + + # Always set prhead to the correct fork URL for this PR. + # The remote is repo-level (shared across worktrees), so a previous + # prepare-pr run for a different fork PR can leave a stale URL. + git remote remove prhead 2>/dev/null || true + git remote add prhead "$push_url" +} + +resolve_prhead_remote_sha() { + local pr_head="$1" + + local remote_sha + remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true) + if [ -z "$remote_sha" ]; then + local https_url + https_url=$(resolve_head_push_url_https 2>/dev/null) || true + local current_push_url + current_push_url=$(git remote get-url prhead 2>/dev/null || true) + if [ -n "$https_url" ] && [ "$https_url" != "$current_push_url" ]; then + echo "SSH remote failed; falling back to HTTPS..." + git remote set-url prhead "$https_url" + git remote set-url --push prhead "$https_url" + remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true) + fi + if [ -z "$remote_sha" ]; then + echo "Remote branch refs/heads/$pr_head not found on prhead" + exit 1 + fi + fi + + printf '%s\n' "$remote_sha" +} + +run_prepare_push_retry_gates() { + local docs_only="${1:-false}" + + bootstrap_deps_if_needed + run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build + run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check + if [ "$docs_only" != "true" ]; then + run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test + fi +} + +PUSH_PREP_HEAD_SHA="" +PUSHED_FROM_SHA="" +PR_HEAD_SHA_AFTER_PUSH="" + +push_prep_head_to_pr_branch() { + local pr="$1" + local pr_head="$2" + local prep_head_sha="$3" + local lease_sha="$4" + local rerun_gates_on_lease_retry="${5:-false}" + local docs_only="${6:-false}" + + setup_prhead_remote + + local remote_sha + remote_sha=$(resolve_prhead_remote_sha "$pr_head") + + local pushed_from_sha="$remote_sha" + if [ "$remote_sha" = "$prep_head_sha" ]; then + echo "Remote branch already at local prep HEAD; skipping push." + else + if [ "$remote_sha" != "$lease_sha" ]; then + echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote." + lease_sha="$remote_sha" + fi + pushed_from_sha="$lease_sha" + local push_output + if ! push_output=$( + git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head 2>&1 + ); then + echo "Push failed: $push_output" + + if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then + echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..." + if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then + local graphql_oid + graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha") + prep_head_sha="$graphql_oid" + else + echo "Git push permission denied and no fork owner/repo info for GraphQL fallback." + exit 1 + fi + else + echo "Lease push failed, retrying once with fresh PR head..." + lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) + pushed_from_sha="$lease_sha" + + if [ "$rerun_gates_on_lease_retry" = "true" ]; then + git fetch origin "pull/$pr/head:pr-$pr-latest" --force + git rebase "pr-$pr-latest" + prep_head_sha=$(git rev-parse HEAD) + run_prepare_push_retry_gates "$docs_only" + fi + + if ! push_output=$( + git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head 2>&1 + ); then + echo "Retry push failed: $push_output" + if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then + echo "Retry failed; trying GraphQL createCommitOnBranch fallback..." + local graphql_oid + graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha") + prep_head_sha="$graphql_oid" + else + echo "Git push failed and no fork owner/repo info for GraphQL fallback." + exit 1 + fi + fi + fi + fi + fi + + if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then + local observed_sha + observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) + echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha" + exit 1 + fi + + local pr_head_sha_after + pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) + + git fetch origin main + git fetch origin "pull/$pr/head:pr-$pr-verify" --force + git merge-base --is-ancestor origin/main "pr-$pr-verify" || { + echo "PR branch is behind main after push." + exit 1 + } + git branch -D "pr-$pr-verify" 2>/dev/null || true + + PUSH_PREP_HEAD_SHA="$prep_head_sha" + PUSHED_FROM_SHA="$pushed_from_sha" + PR_HEAD_SHA_AFTER_PUSH="$pr_head_sha_after" +} + set_review_mode() { local mode="$1" cat > .local/review-mode.env </dev/null || true - git remote add prhead "$push_url" - - local remote_sha - remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) - if [ -z "$remote_sha" ]; then - local https_url - https_url=$(resolve_head_push_url_https 2>/dev/null) || true - if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then - echo "SSH remote failed; falling back to HTTPS..." - git remote set-url prhead "$https_url" - git remote set-url --push prhead "$https_url" - push_url="$https_url" - remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) - fi - if [ -z "$remote_sha" ]; then - echo "Remote branch refs/heads/$PR_HEAD not found on prhead" - exit 1 - fi - fi - - local pushed_from_sha="$remote_sha" - if [ "$remote_sha" = "$prep_head_sha" ]; then - echo "Remote branch already at local prep HEAD; skipping push." - else - if [ "$remote_sha" != "$lease_sha" ]; then - echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote." - lease_sha="$remote_sha" - fi - pushed_from_sha="$lease_sha" - local push_output - if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then - echo "Push failed: $push_output" - - # Check if this is a permission error (fork PR) vs a lease conflict. - # Permission errors go straight to GraphQL; lease conflicts retry with rebase. - if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then - echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..." - if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then - local graphql_oid - graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") - prep_head_sha="$graphql_oid" - else - echo "Git push permission denied and no fork owner/repo info for GraphQL fallback." - exit 1 - fi - else - echo "Lease push failed, retrying once with fresh PR head..." - - lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - pushed_from_sha="$lease_sha" - - git fetch origin "pull/$pr/head:pr-$pr-latest" --force - git rebase "pr-$pr-latest" - prep_head_sha=$(git rev-parse HEAD) - - bootstrap_deps_if_needed - run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build - run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check - if [ "${DOCS_ONLY:-false}" != "true" ]; then - run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test - fi - - if ! git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD; then - # Retry also failed — try GraphQL as last resort. - if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then - echo "Git push retry failed; trying GraphQL createCommitOnBranch fallback..." - local graphql_oid - graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") - prep_head_sha="$graphql_oid" - else - echo "Git push failed and no fork owner/repo info for GraphQL fallback." - exit 1 - fi - fi - fi - fi - fi - - if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then - local observed_sha - observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha" - exit 1 - fi - - local pr_head_sha_after - pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - - git fetch origin main - git fetch origin "pull/$pr/head:pr-$pr-verify" --force - git merge-base --is-ancestor origin/main "pr-$pr-verify" || { - echo "PR branch is behind main after push." - exit 1 - } - git branch -D "pr-$pr-verify" 2>/dev/null || true + verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD" + push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" true "${DOCS_ONLY:-false}" + prep_head_sha="$PUSH_PREP_HEAD_SHA" + local pushed_from_sha="$PUSHED_FROM_SHA" + local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH" local contrib="${PR_AUTHOR:-}" if [ -z "$contrib" ]; then @@ -1464,107 +1514,14 @@ prepare_sync_head() { local prep_head_sha prep_head_sha=$(git rev-parse HEAD) - local current_head - current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName) local lease_sha lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - if [ "$current_head" != "$PR_HEAD" ]; then - echo "PR head branch changed from $PR_HEAD to $current_head. Re-run prepare-init." - exit 1 - fi - - local push_url - push_url=$(resolve_head_push_url) || { - echo "Unable to resolve PR head repo push URL." - exit 1 - } - - # Always set prhead to the correct fork URL for this PR. - # The remote is repo-level (shared across worktrees), so a previous - # run for a different fork PR can leave a stale URL. - git remote remove prhead 2>/dev/null || true - git remote add prhead "$push_url" - - local remote_sha - remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) - if [ -z "$remote_sha" ]; then - local https_url - https_url=$(resolve_head_push_url_https 2>/dev/null) || true - if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then - echo "SSH remote failed; falling back to HTTPS..." - git remote set-url prhead "$https_url" - git remote set-url --push prhead "$https_url" - push_url="$https_url" - remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true) - fi - if [ -z "$remote_sha" ]; then - echo "Remote branch refs/heads/$PR_HEAD not found on prhead" - exit 1 - fi - fi - - local pushed_from_sha="$remote_sha" - if [ "$remote_sha" = "$prep_head_sha" ]; then - echo "Remote branch already at local prep HEAD; skipping push." - else - if [ "$remote_sha" != "$lease_sha" ]; then - echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote." - lease_sha="$remote_sha" - fi - pushed_from_sha="$lease_sha" - local push_output - if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then - echo "Push failed: $push_output" - - if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then - echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..." - if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then - local graphql_oid - graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") - prep_head_sha="$graphql_oid" - else - echo "Git push permission denied and no fork owner/repo info for GraphQL fallback." - exit 1 - fi - else - echo "Lease push failed, retrying once with fresh PR head lease..." - lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - pushed_from_sha="$lease_sha" - - if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then - echo "Retry push failed: $push_output" - if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then - echo "Retry failed; trying GraphQL createCommitOnBranch fallback..." - local graphql_oid - graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha") - prep_head_sha="$graphql_oid" - else - echo "Git push failed and no fork owner/repo info for GraphQL fallback." - exit 1 - fi - fi - fi - fi - fi - - if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then - local observed_sha - observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha" - exit 1 - fi - - local pr_head_sha_after - pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid) - - git fetch origin main - git fetch origin "pull/$pr/head:pr-$pr-verify" --force - git merge-base --is-ancestor origin/main "pr-$pr-verify" || { - echo "PR branch is behind main after push." - exit 1 - } - git branch -D "pr-$pr-verify" 2>/dev/null || true + verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD" + push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" + prep_head_sha="$PUSH_PREP_HEAD_SHA" + local pushed_from_sha="$PUSHED_FROM_SHA" + local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH" local contrib="${PR_AUTHOR:-}" if [ -z "$contrib" ]; then From 79c5c660bba89c3ab5fd2d8c5c25a2f86eb285c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:04:27 +0000 Subject: [PATCH 071/311] fix: treat model api drift as baseUrl refresh --- ...ssing-provider-apikey-from-env-var.test.ts | 24 +++++++++++++- src/agents/models-config.ts | 31 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 9b6318153d32f..ef03fb3863b82 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -65,7 +65,7 @@ async function runCustomProviderMergeTest(params: { baseUrl: string; apiKey: string; api: string; - models: Array<{ id: string; name: string; input: string[] }>; + models: Array<{ id: string; name: string; input: string[]; api?: string }>; }; existingProviderKey?: string; configProviderKey?: string; @@ -261,6 +261,28 @@ describe("models-config", () => { }); }); + it("replaces stale merged baseUrl when only model-level apis change", async () => { + await withTempHome(async () => { + const parsed = await runCustomProviderMergeTest({ + seedProvider: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", // pragma: allowlist secret + api: "", + models: [ + { + id: "agent-model", + name: "Agent model", + input: ["text"], + api: "openai-completions", + }, + ], + }, + }); + expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY"); + expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1"); + }); + }); + it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { await withTempHome(async () => { await writeAgentModelsJson({ diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 7e96e685fe914..db7e3a5f1a781 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -178,6 +178,33 @@ function resolveProviderApi(entry: { api?: unknown } | undefined): string | unde return api || undefined; } +function resolveModelApiSurface(entry: { models?: unknown } | undefined): string | undefined { + if (!Array.isArray(entry?.models)) { + return undefined; + } + + const apis = entry.models + .flatMap((model) => { + if (!model || typeof model !== "object") { + return []; + } + const api = (model as { api?: unknown }).api; + return typeof api === "string" && api.trim() ? [api.trim()] : []; + }) + .toSorted(); + + if (apis.length === 0) { + return undefined; + } + return JSON.stringify(apis); +} + +function resolveProviderApiSurface( + entry: ({ api?: unknown; models?: unknown } & Record) | undefined, +): string | undefined { + return resolveProviderApi(entry) ?? resolveModelApiSurface(entry); +} + function shouldPreserveExistingApiKey(params: { providerKey: string; existing: ExistingProviderConfig; @@ -207,8 +234,8 @@ function shouldPreserveExistingBaseUrl(params: { return false; } - const existingApi = resolveProviderApi(existing); - const nextApi = resolveProviderApi(nextEntry); + const existingApi = resolveProviderApiSurface(existing); + const nextApi = resolveProviderApiSurface(nextEntry); return !existingApi || !nextApi || existingApi === nextApi; } From 75e15216600b4c03165c1ee71c2b4a97d8f22c07 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:05:59 +0000 Subject: [PATCH 072/311] refactor: extract pure models config merge helpers --- src/agents/models-config.merge.test.ts | 90 ++++++++++ src/agents/models-config.merge.ts | 217 ++++++++++++++++++++++++ src/agents/models-config.ts | 224 +------------------------ 3 files changed, 313 insertions(+), 218 deletions(-) create mode 100644 src/agents/models-config.merge.test.ts create mode 100644 src/agents/models-config.merge.ts diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts new file mode 100644 index 0000000000000..223a534e08f4a --- /dev/null +++ b/src/agents/models-config.merge.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + mergeProviderModels, + mergeProviders, + mergeWithExistingProviderSecrets, + type ExistingProviderConfig, +} from "./models-config.merge.js"; +import type { ProviderConfig } from "./models-config.providers.js"; + +describe("models-config merge helpers", () => { + it("refreshes implicit model metadata while preserving explicit reasoning overrides", () => { + const merged = mergeProviderModels( + { + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + input: ["text"], + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 100_000, + }, + ], + } as ProviderConfig, + { + api: "openai-responses", + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + input: ["image"], + reasoning: false, + contextWindow: 2_000_000, + maxTokens: 200_000, + }, + ], + } as ProviderConfig, + ); + + expect(merged.models).toEqual([ + expect.objectContaining({ + id: "gpt-5.4", + input: ["text"], + reasoning: false, + contextWindow: 2_000_000, + maxTokens: 200_000, + }), + ]); + }); + + it("merges explicit providers onto trimmed keys", () => { + const merged = mergeProviders({ + explicit: { + " custom ": { api: "openai-responses", models: [] } as ProviderConfig, + }, + }); + + expect(merged).toEqual({ + custom: expect.objectContaining({ api: "openai-responses" }), + }); + }); + + it("replaces stale baseUrl when model api surface changes", () => { + const merged = mergeWithExistingProviderSecrets({ + nextProviders: { + custom: { + baseUrl: "https://config.example/v1", + models: [{ id: "model", api: "openai-responses" }], + } as ProviderConfig, + }, + existingProviders: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "AGENT_KEY", + models: [{ id: "model", api: "openai-completions" }], + } as ExistingProviderConfig, + }, + secretRefManagedProviders: new Set(), + explicitBaseUrlProviders: new Set(), + }); + + expect(merged.custom).toEqual( + expect.objectContaining({ + apiKey: "AGENT_KEY", + baseUrl: "https://config.example/v1", + }), + ); + }); +}); diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts new file mode 100644 index 0000000000000..da8a4abdaa2ef --- /dev/null +++ b/src/agents/models-config.merge.ts @@ -0,0 +1,217 @@ +import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import type { ProviderConfig } from "./models-config.providers.js"; + +export type ExistingProviderConfig = ProviderConfig & { + apiKey?: string; + baseUrl?: string; + api?: string; +}; + +function isPositiveFiniteTokenLimit(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function resolvePreferredTokenLimit(params: { + explicitPresent: boolean; + explicitValue: unknown; + implicitValue: unknown; +}): number | undefined { + if (params.explicitPresent && isPositiveFiniteTokenLimit(params.explicitValue)) { + return params.explicitValue; + } + if (isPositiveFiniteTokenLimit(params.implicitValue)) { + return params.implicitValue; + } + return isPositiveFiniteTokenLimit(params.explicitValue) ? params.explicitValue : undefined; +} + +function getProviderModelId(model: unknown): string { + if (!model || typeof model !== "object") { + return ""; + } + const id = (model as { id?: unknown }).id; + return typeof id === "string" ? id.trim() : ""; +} + +export function mergeProviderModels( + implicit: ProviderConfig, + explicit: ProviderConfig, +): ProviderConfig { + const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; + const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; + if (implicitModels.length === 0) { + return { ...implicit, ...explicit }; + } + + const implicitById = new Map( + implicitModels + .map((model) => [getProviderModelId(model), model] as const) + .filter(([id]) => Boolean(id)), + ); + const seen = new Set(); + + const mergedModels = explicitModels.map((explicitModel) => { + const id = getProviderModelId(explicitModel); + if (!id) { + return explicitModel; + } + seen.add(id); + const implicitModel = implicitById.get(id); + if (!implicitModel) { + return explicitModel; + } + + const contextWindow = resolvePreferredTokenLimit({ + explicitPresent: "contextWindow" in explicitModel, + explicitValue: explicitModel.contextWindow, + implicitValue: implicitModel.contextWindow, + }); + const maxTokens = resolvePreferredTokenLimit({ + explicitPresent: "maxTokens" in explicitModel, + explicitValue: explicitModel.maxTokens, + implicitValue: implicitModel.maxTokens, + }); + + return { + ...explicitModel, + input: implicitModel.input, + reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning, + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(maxTokens === undefined ? {} : { maxTokens }), + }; + }); + + for (const implicitModel of implicitModels) { + const id = getProviderModelId(implicitModel); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + mergedModels.push(implicitModel); + } + + return { + ...implicit, + ...explicit, + models: mergedModels, + }; +} + +export function mergeProviders(params: { + implicit?: Record | null; + explicit?: Record | null; +}): Record { + const out: Record = params.implicit ? { ...params.implicit } : {}; + for (const [key, explicit] of Object.entries(params.explicit ?? {})) { + const providerKey = key.trim(); + if (!providerKey) { + continue; + } + const implicit = out[providerKey]; + out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit; + } + return out; +} + +function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined { + if (typeof entry?.api !== "string") { + return undefined; + } + const api = entry.api.trim(); + return api || undefined; +} + +function resolveModelApiSurface(entry: { models?: unknown } | undefined): string | undefined { + if (!Array.isArray(entry?.models)) { + return undefined; + } + + const apis = entry.models + .flatMap((model) => { + if (!model || typeof model !== "object") { + return []; + } + const api = (model as { api?: unknown }).api; + return typeof api === "string" && api.trim() ? [api.trim()] : []; + }) + .toSorted(); + + return apis.length > 0 ? JSON.stringify(apis) : undefined; +} + +function resolveProviderApiSurface( + entry: ExistingProviderConfig | ProviderConfig | undefined, +): string | undefined { + return resolveProviderApi(entry) ?? resolveModelApiSurface(entry); +} + +function shouldPreserveExistingApiKey(params: { + providerKey: string; + existing: ExistingProviderConfig; + secretRefManagedProviders: ReadonlySet; +}): boolean { + const { providerKey, existing, secretRefManagedProviders } = params; + return ( + !secretRefManagedProviders.has(providerKey) && + typeof existing.apiKey === "string" && + existing.apiKey.length > 0 && + !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) + ); +} + +function shouldPreserveExistingBaseUrl(params: { + providerKey: string; + existing: ExistingProviderConfig; + nextEntry: ProviderConfig; + explicitBaseUrlProviders: ReadonlySet; +}): boolean { + const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params; + if ( + explicitBaseUrlProviders.has(providerKey) || + typeof existing.baseUrl !== "string" || + existing.baseUrl.length === 0 + ) { + return false; + } + + const existingApi = resolveProviderApiSurface(existing); + const nextApi = resolveProviderApiSurface(nextEntry); + return !existingApi || !nextApi || existingApi === nextApi; +} + +export function mergeWithExistingProviderSecrets(params: { + nextProviders: Record; + existingProviders: Record; + secretRefManagedProviders: ReadonlySet; + explicitBaseUrlProviders: ReadonlySet; +}): Record { + const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = + params; + const mergedProviders: Record = {}; + for (const [key, entry] of Object.entries(existingProviders)) { + mergedProviders[key] = entry; + } + for (const [key, newEntry] of Object.entries(nextProviders)) { + const existing = existingProviders[key]; + if (!existing) { + mergedProviders[key] = newEntry; + continue; + } + const preserved: Record = {}; + if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) { + preserved.apiKey = existing.apiKey; + } + if ( + shouldPreserveExistingBaseUrl({ + providerKey: key, + existing, + nextEntry: newEntry, + explicitBaseUrlProviders, + }) + ) { + preserved.baseUrl = existing.baseUrl; + } + mergedProviders[key] = { ...newEntry, ...preserved }; + } + return mergedProviders; +} diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index db7e3a5f1a781..b72e695976f63 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -9,7 +9,12 @@ import { import { applyConfigEnvVars } from "../config/env-vars.js"; import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; -import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import { + mergeProviders, + mergeProviderModels, + mergeWithExistingProviderSecrets, + type ExistingProviderConfig, +} from "./models-config.merge.js"; import { normalizeProviders, type ProviderConfig, @@ -19,121 +24,10 @@ import { } from "./models-config.providers.js"; type ModelsConfig = NonNullable; -type ExistingProviderConfig = NonNullable[string] & { - apiKey?: string; - baseUrl?: string; - api?: string; -}; const DEFAULT_MODE: NonNullable = "merge"; const MODELS_JSON_WRITE_LOCKS = new Map>(); -function isPositiveFiniteTokenLimit(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value > 0; -} - -function resolvePreferredTokenLimit(params: { - explicitPresent: boolean; - explicitValue: unknown; - implicitValue: unknown; -}): number | undefined { - if (params.explicitPresent && isPositiveFiniteTokenLimit(params.explicitValue)) { - return params.explicitValue; - } - if (isPositiveFiniteTokenLimit(params.implicitValue)) { - return params.implicitValue; - } - return isPositiveFiniteTokenLimit(params.explicitValue) ? params.explicitValue : undefined; -} - -function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig { - const implicitModels = Array.isArray(implicit.models) ? implicit.models : []; - const explicitModels = Array.isArray(explicit.models) ? explicit.models : []; - if (implicitModels.length === 0) { - return { ...implicit, ...explicit }; - } - - const getId = (model: unknown): string => { - if (!model || typeof model !== "object") { - return ""; - } - const id = (model as { id?: unknown }).id; - return typeof id === "string" ? id.trim() : ""; - }; - const implicitById = new Map( - implicitModels.map((model) => [getId(model), model] as const).filter(([id]) => Boolean(id)), - ); - const seen = new Set(); - - const mergedModels = explicitModels.map((explicitModel) => { - const id = getId(explicitModel); - if (!id) { - return explicitModel; - } - seen.add(id); - const implicitModel = implicitById.get(id); - if (!implicitModel) { - return explicitModel; - } - - // 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. - const contextWindow = resolvePreferredTokenLimit({ - explicitPresent: "contextWindow" in explicitModel, - explicitValue: explicitModel.contextWindow, - implicitValue: implicitModel.contextWindow, - }); - const maxTokens = resolvePreferredTokenLimit({ - explicitPresent: "maxTokens" in explicitModel, - explicitValue: explicitModel.maxTokens, - implicitValue: implicitModel.maxTokens, - }); - - return { - ...explicitModel, - input: implicitModel.input, - reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning, - ...(contextWindow === undefined ? {} : { contextWindow }), - ...(maxTokens === undefined ? {} : { maxTokens }), - }; - }); - - for (const implicitModel of implicitModels) { - const id = getId(implicitModel); - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - mergedModels.push(implicitModel); - } - - return { - ...implicit, - ...explicit, - models: mergedModels, - }; -} - -function mergeProviders(params: { - implicit?: Record | null; - explicit?: Record | null; -}): Record { - const out: Record = params.implicit ? { ...params.implicit } : {}; - for (const [key, explicit] of Object.entries(params.explicit ?? {})) { - const providerKey = key.trim(); - if (!providerKey) { - continue; - } - const implicit = out[providerKey]; - out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit; - } - return out; -} - async function readJson(pathname: string): Promise { try { const raw = await fs.readFile(pathname, "utf8"); @@ -170,112 +64,6 @@ async function resolveProvidersForModelsJson(params: { return providers; } -function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined { - if (typeof entry?.api !== "string") { - return undefined; - } - const api = entry.api.trim(); - return api || undefined; -} - -function resolveModelApiSurface(entry: { models?: unknown } | undefined): string | undefined { - if (!Array.isArray(entry?.models)) { - return undefined; - } - - const apis = entry.models - .flatMap((model) => { - if (!model || typeof model !== "object") { - return []; - } - const api = (model as { api?: unknown }).api; - return typeof api === "string" && api.trim() ? [api.trim()] : []; - }) - .toSorted(); - - if (apis.length === 0) { - return undefined; - } - return JSON.stringify(apis); -} - -function resolveProviderApiSurface( - entry: ({ api?: unknown; models?: unknown } & Record) | undefined, -): string | undefined { - return resolveProviderApi(entry) ?? resolveModelApiSurface(entry); -} - -function shouldPreserveExistingApiKey(params: { - providerKey: string; - existing: ExistingProviderConfig; - secretRefManagedProviders: ReadonlySet; -}): boolean { - const { providerKey, existing, secretRefManagedProviders } = params; - return ( - !secretRefManagedProviders.has(providerKey) && - typeof existing.apiKey === "string" && - existing.apiKey.length > 0 && - !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) - ); -} - -function shouldPreserveExistingBaseUrl(params: { - providerKey: string; - existing: ExistingProviderConfig; - nextEntry: ProviderConfig; - explicitBaseUrlProviders: ReadonlySet; -}): boolean { - const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params; - if ( - explicitBaseUrlProviders.has(providerKey) || - typeof existing.baseUrl !== "string" || - existing.baseUrl.length === 0 - ) { - return false; - } - - const existingApi = resolveProviderApiSurface(existing); - const nextApi = resolveProviderApiSurface(nextEntry); - return !existingApi || !nextApi || existingApi === nextApi; -} - -function mergeWithExistingProviderSecrets(params: { - nextProviders: Record; - existingProviders: Record; - secretRefManagedProviders: ReadonlySet; - explicitBaseUrlProviders: ReadonlySet; -}): Record { - const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = - params; - const mergedProviders: Record = {}; - for (const [key, entry] of Object.entries(existingProviders)) { - mergedProviders[key] = entry; - } - for (const [key, newEntry] of Object.entries(nextProviders)) { - const existing = existingProviders[key]; - if (!existing) { - mergedProviders[key] = newEntry; - continue; - } - const preserved: Record = {}; - if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) { - preserved.apiKey = existing.apiKey; - } - if ( - shouldPreserveExistingBaseUrl({ - providerKey: key, - existing, - nextEntry: newEntry, - explicitBaseUrlProviders, - }) - ) { - preserved.baseUrl = existing.baseUrl; - } - mergedProviders[key] = { ...newEntry, ...preserved }; - } - return mergedProviders; -} - async function resolveProvidersForMode(params: { mode: NonNullable; targetPath: string; From b41bcb08a283810e45b3f657992b64c6eec96a10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:07:50 +0000 Subject: [PATCH 073/311] refactor: expand provider capability registry --- src/agents/pi-embedded-runner/extra-params.ts | 24 ++++---- src/agents/provider-capabilities.test.ts | 32 +++++++++- src/agents/provider-capabilities.ts | 41 +++++++++++++ src/agents/transcript-policy.test.ts | 16 +++++ src/agents/transcript-policy.ts | 59 +++++++++++++------ 5 files changed, 138 insertions(+), 34 deletions(-) diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 254d74c8f1d22..7ac14f9ee9831 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -4,6 +4,7 @@ import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import { + requiresOpenAiCompatibleAnthropicToolPayload, usesOpenAiFunctionAnthropicToolSchema, usesOpenAiStringModeAnthropicToolChoice, } from "../provider-capabilities.js"; @@ -790,7 +791,7 @@ function createMoonshotThinkingWrapper( }; } -function requiresAnthropicToolPayloadCompatibility(model: { +function requiresAnthropicToolPayloadCompatibilityForModel(model: { api?: unknown; provider?: unknown; baseUrl?: unknown; @@ -799,7 +800,10 @@ function requiresAnthropicToolPayloadCompatibility(model: { return false; } - if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { + if ( + typeof model.provider === "string" && + requiresOpenAiCompatibleAnthropicToolPayload(model.provider) + ) { return true; } @@ -899,27 +903,19 @@ function createAnthropicToolPayloadCompatibilityWrapper( return underlying(model, context, { ...options, onPayload: (payload) => { + const provider = typeof model.provider === "string" ? model.provider : undefined; if ( payload && typeof payload === "object" && - requiresAnthropicToolPayloadCompatibility(model) + requiresAnthropicToolPayloadCompatibilityForModel(model) ) { const payloadObj = payload as Record; - if ( - Array.isArray(payloadObj.tools) && - usesOpenAiFunctionAnthropicToolSchema( - typeof model.provider === "string" ? model.provider : undefined, - ) - ) { + if (Array.isArray(payloadObj.tools) && usesOpenAiFunctionAnthropicToolSchema(provider)) { payloadObj.tools = payloadObj.tools .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) .filter((tool): tool is Record => !!tool); } - if ( - usesOpenAiStringModeAnthropicToolChoice( - typeof model.provider === "string" ? model.provider : undefined, - ) - ) { + if (usesOpenAiStringModeAnthropicToolChoice(provider)) { payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( payloadObj.tool_choice, ); diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 80290f2019b3b..d080c00f36d7d 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { resolveProviderCapabilities } from "./provider-capabilities.js"; +import { + requiresOpenAiCompatibleAnthropicToolPayload, + resolveProviderCapabilities, + resolveTranscriptToolCallIdMode, + sanitizesGeminiThoughtSignatures, + supportsOpenAiCompatTurnValidation, +} from "./provider-capabilities.js"; describe("resolveProviderCapabilities", () => { it("returns native anthropic defaults for ordinary providers", () => { @@ -7,6 +13,9 @@ describe("resolveProviderCapabilities", () => { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", }); }); @@ -18,6 +27,27 @@ describe("resolveProviderCapabilities", () => { anthropicToolSchemaMode: "openai-functions", anthropicToolChoiceMode: "openai-string-modes", preserveAnthropicThinkingSignatures: false, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", }); }); + + it("flags providers that opt out of OpenAI-compatible turn validation", () => { + expect(supportsOpenAiCompatTurnValidation("openrouter")).toBe(false); + expect(supportsOpenAiCompatTurnValidation("opencode")).toBe(false); + expect(supportsOpenAiCompatTurnValidation("moonshot")).toBe(true); + }); + + it("resolves transcript thought-signature and tool-call quirks through the registry", () => { + expect(sanitizesGeminiThoughtSignatures("openrouter")).toBe(true); + expect(sanitizesGeminiThoughtSignatures("kilocode")).toBe(true); + expect(resolveTranscriptToolCallIdMode("mistral")).toBe("strict9"); + }); + + it("treats kimi aliases as anthropic tool payload compatibility providers", () => { + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(true); + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true); + expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); + }); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 1d3cc055047a9..57fd364fbee61 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -4,12 +4,18 @@ export type ProviderCapabilities = { anthropicToolSchemaMode: "native" | "openai-functions"; anthropicToolChoiceMode: "native" | "openai-string-modes"; preserveAnthropicThinkingSignatures: boolean; + openAiCompatTurnValidation: boolean; + geminiThoughtSignatureSanitization: boolean; + transcriptToolCallIdMode: "default" | "strict9"; }; const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", }; const PROVIDER_CAPABILITIES: Record> = { @@ -18,6 +24,20 @@ const PROVIDER_CAPABILITIES: Record> = { anthropicToolChoiceMode: "openai-string-modes", preserveAnthropicThinkingSignatures: false, }, + mistral: { + transcriptToolCallIdMode: "strict9", + }, + openrouter: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + }, + opencode: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + }, + kilocode: { + geminiThoughtSignatureSanitization: true, + }, }; export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities { @@ -32,6 +52,14 @@ export function preservesAnthropicThinkingSignatures(provider?: string | null): return resolveProviderCapabilities(provider).preserveAnthropicThinkingSignatures; } +export function requiresOpenAiCompatibleAnthropicToolPayload(provider?: string | null): boolean { + const capabilities = resolveProviderCapabilities(provider); + return ( + capabilities.anthropicToolSchemaMode !== "native" || + capabilities.anthropicToolChoiceMode !== "native" + ); +} + export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): boolean { return resolveProviderCapabilities(provider).anthropicToolSchemaMode === "openai-functions"; } @@ -39,3 +67,16 @@ export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): export function usesOpenAiStringModeAnthropicToolChoice(provider?: string | null): boolean { return resolveProviderCapabilities(provider).anthropicToolChoiceMode === "openai-string-modes"; } + +export function supportsOpenAiCompatTurnValidation(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).openAiCompatTurnValidation; +} + +export function sanitizesGeminiThoughtSignatures(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization; +} + +export function resolveTranscriptToolCallIdMode(provider?: string | null): "strict9" | undefined { + const mode = resolveProviderCapabilities(provider).transcriptToolCallIdMode; + return mode === "strict9" ? mode : undefined; +} diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 0737fc43b20da..3534bfad92bb1 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -153,4 +153,20 @@ describe("resolveTranscriptPolicy", () => { expect(policy.validateGeminiTurns).toBe(false); expect(policy.validateAnthropicTurns).toBe(false); }); + + it.each([ + { provider: "openrouter", modelId: "google/gemini-2.5-pro-preview" }, + { provider: "opencode", modelId: "google/gemini-2.5-flash" }, + { provider: "kilocode", modelId: "gemini-2.0-flash" }, + ])("sanitizes Gemini thought signatures for $provider routes", ({ provider, modelId }) => { + const policy = resolveTranscriptPolicy({ + provider, + modelId, + modelApi: "openai-completions", + }); + expect(policy.sanitizeThoughtSignatures).toEqual({ + allowBase64Only: true, + includeCamelCase: true, + }); + }); }); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 5acf96e89e7fa..491c22ab59d52 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,6 +1,11 @@ import { normalizeProviderId } from "./model-selection.js"; import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; -import { preservesAnthropicThinkingSignatures } from "./provider-capabilities.js"; +import { + preservesAnthropicThinkingSignatures, + resolveTranscriptToolCallIdMode, + sanitizesGeminiThoughtSignatures, + supportsOpenAiCompatTurnValidation, +} from "./provider-capabilities.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; export type TranscriptSanitizeMode = "full" | "images-only"; @@ -39,7 +44,6 @@ const OPENAI_MODEL_APIS = new Set([ "openai-codex-responses", ]); const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); -const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -64,16 +68,26 @@ function isAnthropicApi(modelApi?: string | null, provider?: string | null): boo return normalized === "anthropic" || normalized === "amazon-bedrock"; } -function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { - const provider = normalizeProviderId(params.provider ?? ""); - if (provider === "mistral") { - return true; +function isMistralModel(modelId?: string | null): boolean { + const normalizedModelId = (modelId ?? "").toLowerCase(); + if (!normalizedModelId) { + return false; + } + return MISTRAL_MODEL_HINTS.some((hint) => normalizedModelId.includes(hint)); +} + +function shouldSanitizeGeminiThoughtSignatures(params: { + provider?: string | null; + modelId?: string | null; +}): boolean { + if (!sanitizesGeminiThoughtSignatures(params.provider)) { + return false; } const modelId = (params.modelId ?? "").toLowerCase(); if (!modelId) { return false; } - return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint)); + return modelId.includes("gemini"); } export function resolveTranscriptPolicy(params: { @@ -89,11 +103,13 @@ export function resolveTranscriptPolicy(params: { const isStrictOpenAiCompatible = params.modelApi === "openai-completions" && !isOpenAi && - !OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS.has(provider); - const isMistral = isMistralModel({ provider, modelId }); - const isOpenRouterGemini = - (provider === "openrouter" || provider === "opencode" || provider === "kilocode") && - modelId.toLowerCase().includes("gemini"); + supportsOpenAiCompatTurnValidation(provider); + const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider); + const isMistral = providerToolCallIdMode === "strict9" || isMistralModel(modelId); + const shouldSanitizeGeminiThoughtSignaturesForProvider = shouldSanitizeGeminiThoughtSignatures({ + provider, + modelId, + }); const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude"); const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; @@ -102,21 +118,26 @@ export function resolveTranscriptPolicy(params: { // Drop these blocks at send-time to keep sessions usable. const dropThinkingBlocks = isCopilotClaude; - const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini; + const needsNonImageSanitize = + isGoogle || isAnthropic || isMistral || shouldSanitizeGeminiThoughtSignaturesForProvider; const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic || requiresOpenAiCompatibleToolIdSanitization; - const toolCallIdMode: ToolCallIdMode | undefined = isMistral - ? "strict9" - : sanitizeToolCallIds - ? "strict" - : undefined; + const toolCallIdMode: ToolCallIdMode | undefined = providerToolCallIdMode + ? providerToolCallIdMode + : isMistral + ? "strict9" + : sanitizeToolCallIds + ? "strict" + : undefined; // 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; + shouldSanitizeGeminiThoughtSignaturesForProvider || isGoogle + ? { allowBase64Only: true, includeCamelCase: true } + : undefined; return { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", From bec3c0b71d6c4885389d3ff038d9d508243c08f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:18:55 +0000 Subject: [PATCH 074/311] refactor: reuse one models.json read per write --- src/agents/models-config.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b72e695976f63..ef56cc8e72805 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -28,12 +28,21 @@ type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; const MODELS_JSON_WRITE_LOCKS = new Map>(); -async function readJson(pathname: string): Promise { +async function readExistingModelsFile(pathname: string): Promise<{ + raw: string; + parsed: unknown; +}> { try { const raw = await fs.readFile(pathname, "utf8"); - return JSON.parse(raw) as unknown; + return { + raw, + parsed: JSON.parse(raw) as unknown, + }; } catch { - return null; + return { + raw: "", + parsed: null, + }; } } @@ -66,7 +75,7 @@ async function resolveProvidersForModelsJson(params: { async function resolveProvidersForMode(params: { mode: NonNullable; - targetPath: string; + existingParsed: unknown; providers: Record; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; @@ -74,7 +83,7 @@ async function resolveProvidersForMode(params: { if (params.mode !== "merge") { return params.providers; } - const existing = await readJson(params.targetPath); + const existing = params.existingParsed; if (!isRecord(existing) || !isRecord(existing.providers)) { return params.providers; } @@ -90,14 +99,6 @@ async function resolveProvidersForMode(params: { }); } -async function readRawFile(pathname: string): Promise { - try { - return await fs.readFile(pathname, "utf8"); - } catch { - return ""; - } -} - async function ensureModelsFileMode(pathname: string): Promise { await fs.chmod(pathname, 0o600).catch(() => { // best-effort @@ -178,17 +179,17 @@ export async function ensureOpenClawModelsJson( secretDefaults: cfg.secrets?.defaults, secretRefManagedProviders, }) ?? providers; + const existingModelsFile = await readExistingModelsFile(targetPath); const mergedProviders = await resolveProvidersForMode({ mode, - targetPath, + existingParsed: existingModelsFile.parsed, providers: normalizedProviders, secretRefManagedProviders, explicitBaseUrlProviders, }); const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; - const existingRaw = await readRawFile(targetPath); - if (existingRaw === next) { + if (existingModelsFile.raw === next) { await ensureModelsFileMode(targetPath); return { agentDir, wrote: false }; } From bce9d93fb573028ce6f195ef05beac774885f057 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:19:17 +0000 Subject: [PATCH 075/311] fix: publish models.json atomically --- src/agents/models-config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index ef56cc8e72805..9145c5089b99a 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -105,6 +105,12 @@ async function ensureModelsFileMode(pathname: string): Promise { }); } +async function writeModelsFileAtomic(targetPath: string, contents: string): Promise { + const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tempPath, contents, { mode: 0o600 }); + await fs.rename(tempPath, targetPath); +} + function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig { const runtimeSource = getRuntimeConfigSourceSnapshot(); if (!runtimeSource) { @@ -195,7 +201,7 @@ export async function ensureOpenClawModelsJson( } await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await fs.writeFile(targetPath, next, { mode: 0o600 }); + await writeModelsFileAtomic(targetPath, next); await ensureModelsFileMode(targetPath); return { agentDir, wrote: true }; }); From 1ec1f0f1f2d1148e5fc56081f199529220d4c3f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:20:05 +0000 Subject: [PATCH 076/311] refactor: scope prep push results to env artifacts --- scripts/pr | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/pr b/scripts/pr index 709cdb35f50d6..dc0f4e2fc57e8 100755 --- a/scripts/pr +++ b/scripts/pr @@ -485,10 +485,6 @@ run_prepare_push_retry_gates() { fi } -PUSH_PREP_HEAD_SHA="" -PUSHED_FROM_SHA="" -PR_HEAD_SHA_AFTER_PUSH="" - push_prep_head_to_pr_branch() { local pr="$1" local pr_head="$2" @@ -496,6 +492,7 @@ push_prep_head_to_pr_branch() { local lease_sha="$4" local rerun_gates_on_lease_retry="${5:-false}" local docs_only="${6:-false}" + local result_env_path="${7:-.local/push-result.env}" setup_prhead_remote @@ -574,10 +571,11 @@ push_prep_head_to_pr_branch() { exit 1 } git branch -D "pr-$pr-verify" 2>/dev/null || true - - PUSH_PREP_HEAD_SHA="$prep_head_sha" - PUSHED_FROM_SHA="$pushed_from_sha" - PR_HEAD_SHA_AFTER_PUSH="$pr_head_sha_after" + cat > "$result_env_path" < Date: Sun, 8 Mar 2026 16:22:01 +0000 Subject: [PATCH 077/311] refactor: fold implicit provider injection into resolver --- src/agents/model-auth.ts | 7 ++- ...ls-config.providers.discovery-auth.test.ts | 6 +- src/agents/models-config.providers.ts | 59 +++++++++++++++---- src/agents/models-config.ts | 22 ++----- 4 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index b8b0ac9336b35..51ba332ed7f0e 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -271,11 +271,14 @@ export async function resolveApiKeyForProvider(params: { export type EnvApiKeyResult = { apiKey: string; source: string }; export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown"; -export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { +export function resolveEnvApiKey( + provider: string, + env: NodeJS.ProcessEnv = process.env, +): EnvApiKeyResult | null { const normalized = normalizeProviderId(provider); const applied = new Set(getShellEnvAppliedKeys()); const pick = (envVar: string): EnvApiKeyResult | null => { - const value = normalizeOptionalSecretInput(process.env[envVar]); + const value = normalizeOptionalSecretInput(env[envVar]); if (!value) { return null; } diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts index 6e8ebfbc0ac1a..c5c79b2fb9558 100644 --- a/src/agents/models-config.providers.discovery-auth.test.ts +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -63,7 +63,7 @@ describe("provider discovery auth marker guardrails", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProviders({ agentDir, env: {} }); expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); const request = fetchMock.mock.calls[0]?.[1] as | { headers?: Record } @@ -96,7 +96,7 @@ describe("provider discovery auth marker guardrails", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProviders({ agentDir, env: {} }); expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("router.huggingface.co"), @@ -132,7 +132,7 @@ describe("provider discovery auth marker guardrails", () => { "utf8", ); - await resolveImplicitProviders({ agentDir }); + await resolveImplicitProviders({ agentDir, env: {} }); const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000")); const request = vllmCall?.[1] as { headers?: Record } | undefined; expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE"); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 9232e45ae8523..8ae03757beab4 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -403,8 +403,11 @@ function normalizeApiKeyConfig(value: string): string { return match?.[1] ?? trimmed; } -function resolveEnvApiKeyVarName(provider: string): string | undefined { - const resolved = resolveEnvApiKey(provider); +function resolveEnvApiKeyVarName( + provider: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const resolved = resolveEnvApiKey(provider, env); if (!resolved) { return undefined; } @@ -470,6 +473,7 @@ function toDiscoveryApiKey(value: string | undefined): string | undefined { function resolveApiKeyFromCredential( cred: ReturnType["profiles"][string] | undefined, + env: NodeJS.ProcessEnv = process.env, ): ProfileApiKeyResolution | undefined { if (!cred) { return undefined; @@ -482,7 +486,7 @@ function resolveApiKeyFromCredential( return { apiKey: envVar, source: "env-ref", - discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + discoveryApiKey: toDiscoveryApiKey(env[envVar]), }; } return { @@ -507,7 +511,7 @@ function resolveApiKeyFromCredential( return { apiKey: envVar, source: "env-ref", - discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + discoveryApiKey: toDiscoveryApiKey(env[envVar]), }; } return { @@ -529,10 +533,11 @@ function resolveApiKeyFromCredential( function resolveApiKeyFromProfiles(params: { provider: string; store: ReturnType; + env?: NodeJS.ProcessEnv; }): ProfileApiKeyResolution | undefined { const ids = listProfilesForProvider(params.store, params.provider); for (const id of ids) { - const resolved = resolveApiKeyFromCredential(params.store.profiles[id]); + const resolved = resolveApiKeyFromCredential(params.store.profiles[id], params.env); if (resolved) { return resolved; } @@ -1117,23 +1122,26 @@ async function buildKilocodeProviderWithDiscovery(): Promise { export async function resolveImplicitProviders(params: { agentDir: string; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; explicitProviders?: Record | null; }): Promise { const providers: Record = {}; + const env = params.env ?? process.env; const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); const resolveProviderApiKey = ( provider: string, ): { apiKey: string | undefined; discoveryApiKey?: string } => { - const envVar = resolveEnvApiKeyVarName(provider); + const envVar = resolveEnvApiKeyVarName(provider, env); if (envVar) { return { apiKey: envVar, - discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + discoveryApiKey: toDiscoveryApiKey(env[envVar]), }; } - const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore }); + const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore, env }); return { apiKey: fromProfiles?.apiKey, discoveryApiKey: fromProfiles?.discoveryApiKey, @@ -1145,7 +1153,7 @@ export async function resolveImplicitProviders(params: { providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; } - const minimaxPortalEnvKey = resolveEnvApiKeyVarName("minimax-portal"); + const minimaxPortalEnvKey = resolveEnvApiKeyVarName("minimax-portal", env); const minimaxOauthProfile = listProfilesForProvider(authStore, "minimax-portal"); if (minimaxPortalEnvKey || minimaxOauthProfile.length > 0) { providers["minimax-portal"] = { @@ -1220,8 +1228,8 @@ export async function resolveImplicitProviders(params: { if (!baseUrl) { continue; } - const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway"); - const profileApiKey = resolveApiKeyFromCredential(cred)?.apiKey; + const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway", env); + const profileApiKey = resolveApiKeyFromCredential(cred, env)?.apiKey; const apiKey = envVarApiKey ?? profileApiKey ?? ""; if (!apiKey) { continue; @@ -1329,6 +1337,35 @@ export async function resolveImplicitProviders(params: { providers.kilocode = { ...(await buildKilocodeProviderWithDiscovery()), apiKey: kilocodeKey }; } + if (!providers["github-copilot"]) { + const implicitCopilot = await resolveImplicitCopilotProvider({ + agentDir: params.agentDir, + env, + }); + if (implicitCopilot) { + providers["github-copilot"] = implicitCopilot; + } + } + + const implicitBedrock = await resolveImplicitBedrockProvider({ + agentDir: params.agentDir, + config: params.config, + env, + }); + if (implicitBedrock) { + const existing = providers["amazon-bedrock"]; + providers["amazon-bedrock"] = existing + ? { + ...implicitBedrock, + ...existing, + models: + Array.isArray(existing.models) && existing.models.length > 0 + ? existing.models + : implicitBedrock.models, + } + : implicitBedrock; + } + return providers; } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 9145c5089b99a..9a470ee5cdcfd 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -11,15 +11,12 @@ import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { mergeProviders, - mergeProviderModels, mergeWithExistingProviderSecrets, type ExistingProviderConfig, } from "./models-config.merge.js"; import { normalizeProviders, type ProviderConfig, - resolveImplicitBedrockProvider, - resolveImplicitCopilotProvider, resolveImplicitProviders, } from "./models-config.providers.js"; @@ -52,24 +49,15 @@ async function resolveProvidersForModelsJson(params: { }): Promise> { const { cfg, agentDir } = params; const explicitProviders = cfg.models?.providers ?? {}; - const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders }); + const implicitProviders = await resolveImplicitProviders({ + agentDir, + config: cfg, + explicitProviders, + }); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders, }); - - const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg }); - if (implicitBedrock) { - const existing = providers["amazon-bedrock"]; - providers["amazon-bedrock"] = existing - ? mergeProviderModels(implicitBedrock, existing) - : implicitBedrock; - } - - const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir }); - if (implicitCopilot && !providers["github-copilot"]) { - providers["github-copilot"] = implicitCopilot; - } return providers; } From d4ab731746a5078d0d525c8f6ab45b579ee713c8 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 8 Mar 2026 21:06:25 +0530 Subject: [PATCH 078/311] fix(telegram): use message previews in DMs --- CHANGELOG.md | 1 + docs/channels/telegram.md | 10 ++++------ docs/concepts/streaming.md | 2 +- src/telegram/bot-message-dispatch.test.ts | 20 ++++++++++++-------- src/telegram/bot-message-dispatch.ts | 8 +++++--- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a0571cb7a36..083dfab122e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. +- Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one. - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index e50590c8427e6..f49ea5fe3f76b 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -232,10 +232,10 @@ curl "https://api.telegram.org/bot/getUpdates" ## Feature reference - + OpenClaw can stream partial replies in real time: - - direct chats: Telegram native draft streaming via `sendMessageDraft` + - direct chats: preview message + `editMessageText` - groups/topics: preview message + `editMessageText` Requirement: @@ -244,11 +244,9 @@ curl "https://api.telegram.org/bot/getUpdates" - `progress` maps to `partial` on Telegram (compat with cross-channel naming) - legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped - Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026). - For text-only replies: - - DM: OpenClaw updates the draft in place (no extra preview message) + - DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message) - group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message) For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. @@ -872,7 +870,7 @@ Primary reference: - `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). -- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available. +- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). Telegram preview streaming uses a single preview message that is edited in place. - `channels.telegram.mediaMaxMb`: inbound/outbound Telegram media cap (MB, default: 100). - `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 382dc730ccc65..c31048cb26849 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -138,7 +138,7 @@ Legacy key migration: Telegram: -- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates. +- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics. - Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming). - `/reasoning stream` can write reasoning to preview. diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 1e8202bce672f..8972532e13911 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -1171,7 +1171,7 @@ describe("dispatchTelegramMessage draft streaming", () => { }, ); - it("uses message preview transport for DM reasoning lane when answer preview lane is active", async () => { + it("uses message preview transport for all DM lanes when streaming is active", async () => { setupDraftStreams({ answerMessageId: 999, reasoningMessageId: 111 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { @@ -1190,7 +1190,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ thread: { id: 777, scope: "dm" }, - previewTransport: "auto", + previewTransport: "message", }), ); expect(createTelegramDraftStream.mock.calls[1]?.[0]).toEqual( @@ -1201,9 +1201,8 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); - it("materializes DM answer draft final without sending a duplicate final message", async () => { - const answerDraftStream = createTestDraftStream({ previewMode: "draft" }); - answerDraftStream.materialize.mockResolvedValue(321); + it("finalizes DM answer preview in place without materializing or sending a duplicate", async () => { + const answerDraftStream = createDraftStream(321); const reasoningDraftStream = createDraftStream(111); createTelegramDraftStream .mockImplementationOnce(() => answerDraftStream) @@ -1222,12 +1221,17 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ thread: { id: 777, scope: "dm" }, - previewTransport: "auto", + previewTransport: "message", }), ); - expect(answerDraftStream.materialize).toHaveBeenCalledTimes(1); + expect(answerDraftStream.materialize).not.toHaveBeenCalled(); expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 321, + "Checking the directory...", + expect.any(Object), + ); }); it("keeps reasoning and answer streaming in separate preview lanes", async () => { diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 859a35688f685..63e7b6e8e8f06 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -190,19 +190,21 @@ export const dispatchTelegramMessage = async ({ const draftReplyToMessageId = replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined; const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS; + // Keep DM preview lanes on real message transport. Native draft previews still + // require a draft->message materialize hop, and that overlap keeps reintroducing + // a visible duplicate flash at finalize time. + const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft; const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const archivedAnswerPreviews: ArchivedPreview[] = []; const archivedReasoningPreviewIds: number[] = []; const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => { - const useMessagePreviewTransportForDmReasoning = - laneName === "reasoning" && threadSpec?.scope === "dm" && canStreamAnswerDraft; const stream = enabled ? createTelegramDraftStream({ api: bot.api, chatId, maxChars: draftMaxChars, thread: threadSpec, - previewTransport: useMessagePreviewTransportForDmReasoning ? "message" : "auto", + previewTransport: useMessagePreviewTransportForDm ? "message" : "auto", replyToMessageId: draftReplyToMessageId, minInitialChars: draftMinInitialChars, renderText: renderDraftPreview, From b6520d71726869fcd3a5fba82f8afce401195ec6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 10:29:25 -0700 Subject: [PATCH 079/311] CI: scope CodeQL JavaScript analysis --- .../codeql/codeql-javascript-typescript.yml | 18 ++++++++++++++++++ .github/workflows/codeql.yml | 6 ++++++ 2 files changed, 24 insertions(+) create mode 100644 .github/codeql/codeql-javascript-typescript.yml diff --git a/.github/codeql/codeql-javascript-typescript.yml b/.github/codeql/codeql-javascript-typescript.yml new file mode 100644 index 0000000000000..5a765db539294 --- /dev/null +++ b/.github/codeql/codeql-javascript-typescript.yml @@ -0,0 +1,18 @@ +name: openclaw-codeql-javascript-typescript + +paths: + - src + - extensions + - ui/src + - skills + +paths-ignore: + - apps + - dist + - docs + - "**/node_modules" + - "**/coverage" + - "**/*.test.ts" + - "**/*.test.tsx" + - "**/*.e2e.test.ts" + - "**/*.e2e.test.tsx" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 57b0683e03c1f..9b78a3c61727c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,6 +28,7 @@ jobs: needs_swift_tools: false needs_manual_build: false needs_autobuild: false + config_file: ./.github/codeql/codeql-javascript-typescript.yml - language: actions runs_on: blacksmith-16vcpu-ubuntu-2404 needs_node: false @@ -36,6 +37,7 @@ jobs: needs_swift_tools: false needs_manual_build: false needs_autobuild: false + config_file: "" - language: python runs_on: blacksmith-16vcpu-ubuntu-2404 needs_node: false @@ -44,6 +46,7 @@ jobs: needs_swift_tools: false needs_manual_build: false needs_autobuild: false + config_file: "" - language: java-kotlin runs_on: blacksmith-16vcpu-ubuntu-2404 needs_node: false @@ -52,6 +55,7 @@ jobs: needs_swift_tools: false needs_manual_build: true needs_autobuild: false + config_file: "" - language: swift runs_on: macos-latest needs_node: false @@ -60,6 +64,7 @@ jobs: needs_swift_tools: true needs_manual_build: true needs_autobuild: false + config_file: "" steps: - name: Checkout uses: actions/checkout@v4 @@ -95,6 +100,7 @@ jobs: with: languages: ${{ matrix.language }} queries: security-and-quality + config-file: ${{ matrix.config_file || '' }} - name: Autobuild if: matrix.needs_autobuild From caf1b84822a01a4a008219ae415931e078a04ba4 Mon Sep 17 00:00:00 2001 From: GitBuck Date: Sun, 8 Mar 2026 18:47:34 +0100 Subject: [PATCH 080/311] feat: allow compaction model override via config (#38753) Merged via squash. Prepared head SHA: a3d6d6c845c9ef492370c4cc12ea790ca92123f0 Co-authored-by: starbuck100 <25417736+starbuck100@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + docs/concepts/compaction.md | 30 +++++++++ docs/gateway/configuration-reference.md | 2 + scripts/test-parallel.mjs | 2 + src/agents/models-config.merge.test.ts | 5 +- src/agents/pi-embedded-runner/compact.ts | 29 +++++++- .../pi-embedded-runner/run/attempt.test.ts | 66 +++++++++++++++++++ src/config/schema.help.quality.test.ts | 4 ++ src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.agent-defaults.ts | 4 ++ src/config/zod-schema.agent-defaults.ts | 1 + 12 files changed, 143 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 083dfab122e58..a5fe54b1fa09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm. - Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom. - Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs. +- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100. ### Breaking diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index 8d243bf234d1c..73f6372c3f727 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -24,6 +24,36 @@ Compaction **persists** in the session’s JSONL history. Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.). Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`. +You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string: + +```json +{ + "agents": { + "defaults": { + "compaction": { + "model": "openrouter/anthropic/claude-sonnet-4-5" + } + } + } +} +``` + +This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist: + +```json +{ + "agents": { + "defaults": { + "compaction": { + "model": "ollama/llama3.1:8b" + } + } + } +} +``` + +When unset, compaction uses the agent's primary model. + ## Auto-compaction (default on) When a session nears or exceeds the model’s context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 880ccdd198bc0..ca6a368141055 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1005,6 +1005,7 @@ Periodic heartbeat runs. identifierPolicy: "strict", // strict | off | custom identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection + model: "openrouter/anthropic/claude-sonnet-4-5", // optional compaction-only model override memoryFlush: { enabled: true, softThresholdTokens: 6000, @@ -1021,6 +1022,7 @@ Periodic heartbeat runs. - `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. - `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback. +- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model. - `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only. ### `agents.defaults.contextPruning` diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index d524fb8743861..f57a056904740 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -86,6 +86,8 @@ const unitIsolatedFilesRaw = [ "src/slack/monitor/slash.test.ts", // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. "src/imessage/monitor.shutdown.unhandled-rejection.test.ts", + // Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane. + "src/infra/git-commit.test.ts", ]; const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index 223a534e08f4a..b76b3509e2614 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -52,7 +52,10 @@ describe("models-config merge helpers", () => { it("merges explicit providers onto trimmed keys", () => { const merged = mergeProviders({ explicit: { - " custom ": { api: "openai-responses", models: [] } as ProviderConfig, + " custom ": { + api: "openai-responses", + models: [] as ProviderConfig["models"], + } as ProviderConfig, }, }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 05fa3490658ff..3a51da222719b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -271,8 +271,31 @@ export async function compactEmbeddedPiSessionDirect( const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); - const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; - const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + // Resolve compaction model: prefer config override, then fall back to caller-supplied model + const compactionModelOverride = params.config?.agents?.defaults?.compaction?.model?.trim(); + let provider: string; + let modelId: string; + // When switching provider via override, drop the primary auth profile to avoid + // sending the wrong credentials (e.g. OpenAI profile token to OpenRouter). + let authProfileId: string | undefined = params.authProfileId; + if (compactionModelOverride) { + const slashIdx = compactionModelOverride.indexOf("/"); + if (slashIdx > 0) { + provider = compactionModelOverride.slice(0, slashIdx).trim(); + modelId = compactionModelOverride.slice(slashIdx + 1).trim() || DEFAULT_MODEL; + // Provider changed — drop primary auth profile so getApiKeyForModel + // falls back to provider-based key resolution for the override model. + if (provider !== (params.provider ?? "").trim()) { + authProfileId = undefined; + } + } else { + provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; + modelId = compactionModelOverride; + } + } else { + provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; + modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + } const fail = (reason: string): EmbeddedPiCompactResult => { log.warn( `[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` + @@ -302,7 +325,7 @@ export async function compactEmbeddedPiSessionDirect( const apiKeyInfo = await getApiKeyForModel({ model, cfg: params.config, - profileId: params.authProfileId, + profileId: authProfileId, agentDir, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 197a2903183a3..649679632e83f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -639,6 +639,72 @@ describe("prependSystemPromptAddition", () => { }); describe("buildAfterTurnLegacyCompactionParams", () => { + it("uses primary model when compaction.model is not set", () => { + const legacy = buildAfterTurnLegacyCompactionParams({ + attempt: { + sessionKey: "agent:main:session:abc", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + authProfileId: "openai:p1", + config: {} as OpenClawConfig, + skillsSnapshot: undefined, + senderIsOwner: true, + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + expect(legacy).toMatchObject({ + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + }); + + it("passes primary model through even when compaction.model is set (override resolved in compactDirect)", () => { + const legacy = buildAfterTurnLegacyCompactionParams({ + attempt: { + sessionKey: "agent:main:session:abc", + messageChannel: "slack", + messageProvider: "slack", + agentAccountId: "acct-1", + authProfileId: "openai:p1", + config: { + agents: { + defaults: { + compaction: { + model: "openrouter/anthropic/claude-sonnet-4-5", + }, + }, + }, + } as OpenClawConfig, + skillsSnapshot: undefined, + senderIsOwner: true, + provider: "openai-codex", + modelId: "gpt-5.3-codex", + thinkLevel: "off", + reasoningLevel: "on", + extraSystemPrompt: "extra", + ownerNumbers: ["+15555550123"], + }, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + }); + + // buildAfterTurnLegacyCompactionParams no longer resolves the override; + // compactEmbeddedPiSessionDirect does it centrally for both auto + manual paths. + expect(legacy).toMatchObject({ + provider: "openai-codex", + model: "gpt-5.3-codex", + }); + }); + it("includes resolved auth profile fields for context-engine afterTurn compaction", () => { const legacy = buildAfterTurnLegacyCompactionParams({ attempt: { diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 6cb8e4899204d..fa9451456bf15 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -378,6 +378,7 @@ const TARGET_KEYS = [ "agents.defaults.compaction.qualityGuard.enabled", "agents.defaults.compaction.qualityGuard.maxRetries", "agents.defaults.compaction.postCompactionSections", + "agents.defaults.compaction.model", "agents.defaults.compaction.memoryFlush", "agents.defaults.compaction.memoryFlush.enabled", "agents.defaults.compaction.memoryFlush.softThresholdTokens", @@ -810,6 +811,9 @@ describe("config help copy quality", () => { expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true); expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true); + const compactionModel = FIELD_HELP["agents.defaults.compaction.model"]; + expect(/provider\/model|different model|primary agent model/i.test(compactionModel)).toBe(true); + const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"]; expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 01f8c40ff6a88..50d9502ffeab8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1013,6 +1013,8 @@ export const FIELD_HELP: Record = { "Maximum number of regeneration retries after a failed safeguard summary quality audit. Use small values to bound extra latency and token cost.", "agents.defaults.compaction.postCompactionSections": 'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.', + "agents.defaults.compaction.model": + "Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.", "agents.defaults.compaction.memoryFlush": "Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.", "agents.defaults.compaction.memoryFlush.enabled": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 46350c15d94b0..f8961d9e8dda4 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -458,6 +458,7 @@ export const FIELD_LABELS: Record = { "agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled", "agents.defaults.compaction.qualityGuard.maxRetries": "Compaction Quality Guard Max Retries", "agents.defaults.compaction.postCompactionSections": "Post-Compaction Context Sections", + "agents.defaults.compaction.model": "Compaction Model Override", "agents.defaults.compaction.memoryFlush": "Compaction Memory Flush", "agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled", "agents.defaults.compaction.memoryFlush.softThresholdTokens": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index a242d0bbcc1ed..9124e4084d88b 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -322,6 +322,10 @@ export type AgentCompactionConfig = { * Set to [] to disable post-compaction context injection entirely. */ postCompactionSections?: string[]; + /** Optional model override for compaction summarization (e.g. "openrouter/anthropic/claude-sonnet-4-5"). + * When set, compaction uses this model instead of the agent's primary model. + * Falls back to the primary model when unset. */ + model?: string; }; export type AgentCompactionMemoryFlushConfig = { diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 1e83a92f54c59..242d6959729a8 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -104,6 +104,7 @@ export const AgentDefaultsSchema = z .strict() .optional(), postCompactionSections: z.array(z.string()).optional(), + model: z.string().optional(), memoryFlush: z .object({ enabled: z.boolean().optional(), From 6c9b49a10b09fbde869fd175cb3ebce6deb3893d Mon Sep 17 00:00:00 2001 From: yuweuii <82372187+yuweuii@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:59:16 +0800 Subject: [PATCH 081/311] fix(sessions): clear stale contextTokens on model switch (#38044) Merged via squash. Prepared head SHA: bac2df4b7f920ce271f0a15f1db9ed99b35300f3 Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + scripts/test-parallel.mjs | 2 ++ src/auto-reply/status.test.ts | 34 +++++++++++++++++++ src/cli/daemon-cli/lifecycle.test.ts | 21 ++++++------ src/infra/git-commit.test.ts | 3 ++ src/process/supervisor/adapters/child.test.ts | 6 +++- src/sessions/model-overrides.test.ts | 32 +++++++++++++++++ src/sessions/model-overrides.ts | 11 ++++++ 8 files changed, 99 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fe54b1fa09b..c66544475f28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. - Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. +- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. ## 2026.3.7 diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index f57a056904740..65cad7ee6f10d 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -31,6 +31,8 @@ const unitIsolatedFilesRaw = [ "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", + // Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes. + "src/infra/git-commit.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", diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 0f58159ff1161..e58f03e0c1340 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; +import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js"; import { buildCommandsMessage, @@ -172,6 +173,39 @@ describe("buildStatusMessage", () => { expect(normalizeTestText(text)).toContain("Context: 200k/1.0m"); }); + it("recomputes context window from the active model after switching away from a smaller session override", () => { + const sessionEntry = { + sessionId: "switch-back", + updatedAt: 0, + providerOverride: "local", + modelOverride: "small-model", + contextTokens: 4_096, + totalTokens: 1_024, + }; + + applyModelOverrideToSessionEntry({ + entry: sessionEntry, + selection: { + provider: "local", + model: "large-model", + isDefault: true, + }, + }); + + const text = buildStatusMessage({ + agent: { + model: "local/large-model", + contextTokens: 65_536, + }, + sessionEntry, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + }); + + expect(normalizeTestText(text)).toContain("Context: 1.0k/66k"); + }); + it("uses per-agent sandbox config when config and session key are provided", () => { const text = buildStatusMessage({ config: { diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index f1e87fc4938c0..3f0ed6d531c30 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> ->(); +const probeGateway = + vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> + >(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index d0905e8fb8462..20c7238391c1e 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -43,6 +43,9 @@ describe("git commit resolution", () => { afterEach(() => { process.chdir(originalCwd); + vi.restoreAllMocks(); + vi.doUnmock("node:fs"); + vi.doUnmock("node:module"); vi.resetModules(); }); diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 88885800b57d2..8494a701c7ee6 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -1,7 +1,7 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({ spawnWithFallbackMock: vi.fn(), @@ -58,6 +58,10 @@ describe("createChildAdapter", () => { beforeEach(() => { spawnWithFallbackMock.mockClear(); killProcessTreeMock.mockClear(); + delete process.env.OPENCLAW_SERVICE_MARKER; + }); + + afterAll(() => { if (originalServiceMarker === undefined) { delete process.env.OPENCLAW_SERVICE_MARKER; } else { diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index cdfe154b2c47f..7545cd49548b4 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -30,6 +30,7 @@ describe("applyModelOverrideToSessionEntry", () => { model: "claude-sonnet-4-6", providerOverride: "anthropic", modelOverride: "claude-sonnet-4-6", + contextTokens: 160_000, fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6", fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6", fallbackNoticeReason: "provider temporary failure", @@ -39,6 +40,7 @@ describe("applyModelOverrideToSessionEntry", () => { expect(result.updated).toBe(true); expectRuntimeModelFieldsCleared(entry, before); + expect(entry.contextTokens).toBeUndefined(); expect(entry.fallbackNoticeSelectedModel).toBeUndefined(); expect(entry.fallbackNoticeActiveModel).toBeUndefined(); expect(entry.fallbackNoticeReason).toBeUndefined(); @@ -53,12 +55,14 @@ describe("applyModelOverrideToSessionEntry", () => { model: "claude-sonnet-4-6", providerOverride: "openai", modelOverride: "gpt-5.2", + contextTokens: 160_000, }; const result = applyOpenAiSelection(entry); expect(result.updated).toBe(true); expectRuntimeModelFieldsCleared(entry, before); + expect(entry.contextTokens).toBeUndefined(); }); it("retains aligned runtime model fields when selection and runtime already match", () => { @@ -70,6 +74,7 @@ describe("applyModelOverrideToSessionEntry", () => { model: "gpt-5.2", providerOverride: "openai", modelOverride: "gpt-5.2", + contextTokens: 200_000, }; const result = applyModelOverrideToSessionEntry({ @@ -83,6 +88,33 @@ describe("applyModelOverrideToSessionEntry", () => { expect(result.updated).toBe(false); expect(entry.modelProvider).toBe("openai"); expect(entry.model).toBe("gpt-5.2"); + expect(entry.contextTokens).toBe(200_000); expect(entry.updatedAt).toBe(before); }); + + it("clears stale contextTokens when switching back to the default model", () => { + const before = Date.now() - 5_000; + const entry: SessionEntry = { + sessionId: "sess-4", + updatedAt: before, + providerOverride: "local", + modelOverride: "sunapi386/llama-3-lexi-uncensored:8b", + contextTokens: 4_096, + }; + + const result = applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "local", + model: "llama3.1:8b", + isDefault: true, + }, + }); + + expect(result.updated).toBe(true); + expect(entry.providerOverride).toBeUndefined(); + expect(entry.modelOverride).toBeUndefined(); + expect(entry.contextTokens).toBeUndefined(); + expect((entry.updatedAt ?? 0) > before).toBe(true); + }); }); diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index 910d324ee0829..dbbc95e23b77a 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -61,6 +61,17 @@ export function applyModelOverrideToSessionEntry(params: { } } + // contextTokens are derived from the active session model. When the selected + // model changes (or runtime model is already stale), the cached window can + // pin the session to an older/smaller limit until another run refreshes it. + if ( + entry.contextTokens !== undefined && + (selectionUpdated || (runtimePresent && !runtimeAligned)) + ) { + delete entry.contextTokens; + updated = true; + } + if (profileOverride) { if (entry.authProfileOverride !== profileOverride) { entry.authProfileOverride = profileOverride; From 74624e619d41be10b94d165f065693190935ff1d Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:00:24 -0500 Subject: [PATCH 082/311] fix: prefer bundled channel plugins over npm duplicates (#40094) * fix: prefer bundled channel plugins over npm duplicates * fix: tighten bundled plugin review follow-ups * fix: address check gate follow-ups * docs: add changelog for bundled plugin install fix * fix: align lifecycle test formatting with CI oxfmt --- CHANGELOG.md | 1 + src/cli/plugin-install-plan.test.ts | 48 ++++++++++ src/cli/plugin-install-plan.ts | 30 ++++++ .../onboarding/plugin-install.test.ts | 83 +++++++++++++++- src/commands/onboarding/plugin-install.ts | 22 ++++- src/plugins/bundled-sources.test.ts | 36 ++++++- src/plugins/bundled-sources.ts | 36 ++++--- src/plugins/update.test.ts | 95 +++++++++++++++++++ src/plugins/update.ts | 42 +++----- 9 files changed, 343 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c66544475f28c..f93fbf78154ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092) - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index b81ef764298b2..9aca36493d010 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js"; import { + resolveBundledInstallPlanForCatalogEntry, resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, } from "./plugin-install-plan.js"; @@ -34,6 +35,53 @@ describe("plugin install plan helpers", () => { expect(result).toBeNull(); }); + it("prefers bundled catalog plugin by id before npm spec", () => { + const findBundledSource = vi + .fn() + .mockImplementation(({ kind, value }: { kind: "pluginId" | "npmSpec"; value: string }) => { + if (kind === "pluginId" && value === "voice-call") { + return { + pluginId: "voice-call", + localPath: "/tmp/extensions/voice-call", + npmSpec: "@openclaw/voice-call", + }; + } + return undefined; + }); + + const result = resolveBundledInstallPlanForCatalogEntry({ + pluginId: "voice-call", + npmSpec: "@openclaw/voice-call", + findBundledSource, + }); + + expect(findBundledSource).toHaveBeenCalledWith({ kind: "pluginId", value: "voice-call" }); + expect(result?.bundledSource.localPath).toBe("/tmp/extensions/voice-call"); + }); + + it("rejects npm-spec matches that resolve to a different plugin id", () => { + const findBundledSource = vi + .fn() + .mockImplementation(({ kind }: { kind: "pluginId" | "npmSpec"; value: string }) => { + if (kind === "npmSpec") { + return { + pluginId: "not-voice-call", + localPath: "/tmp/extensions/not-voice-call", + npmSpec: "@openclaw/voice-call", + }; + } + return undefined; + }); + + const result = resolveBundledInstallPlanForCatalogEntry({ + pluginId: "voice-call", + npmSpec: "@openclaw/voice-call", + findBundledSource, + }); + + expect(result).toBeNull(); + }); + it("uses npm-spec bundled fallback only for package-not-found", () => { const findBundledSource = vi.fn().mockReturnValue({ pluginId: "voice-call", diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index fbb399a48cbcf..6c2467c15b7f9 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -12,6 +12,36 @@ function isBareNpmPackageName(spec: string): boolean { return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); } +export function resolveBundledInstallPlanForCatalogEntry(params: { + pluginId: string; + npmSpec: string; + findBundledSource: BundledLookup; +}): { bundledSource: BundledPluginSource } | null { + const pluginId = params.pluginId.trim(); + const npmSpec = params.npmSpec.trim(); + if (!pluginId || !npmSpec) { + return null; + } + + const bundledById = params.findBundledSource({ + kind: "pluginId", + value: pluginId, + }); + if (bundledById?.pluginId === pluginId) { + return { bundledSource: bundledById }; + } + + const bundledBySpec = params.findBundledSource({ + kind: "npmSpec", + value: npmSpec, + }); + if (bundledBySpec?.pluginId === pluginId) { + return { bundledSource: bundledBySpec }; + } + + return null; +} + export function resolveBundledInstallPlanBeforeNpm(params: { rawSpec: string; findBundledSource: BundledLookup; diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index fbc2049684f47..b04769dcb54dc 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -1,17 +1,50 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("node:fs", () => ({ - default: { - existsSync: vi.fn(), - }, -})); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const existsSync = vi.fn(); + return { + ...actual, + existsSync, + default: { + ...actual, + existsSync, + }, + }; +}); const installPluginFromNpmSpec = vi.fn(); vi.mock("../../plugins/install.js", () => ({ installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), })); +const resolveBundledPluginSources = vi.fn(); +vi.mock("../../plugins/bundled-sources.js", () => ({ + findBundledPluginSourceInMap: ({ + bundled, + lookup, + }: { + bundled: ReadonlyMap; + lookup: { kind: "pluginId" | "npmSpec"; value: string }; + }) => { + const targetValue = lookup.value.trim(); + if (!targetValue) { + return undefined; + } + if (lookup.kind === "pluginId") { + return bundled.get(targetValue); + } + for (const source of bundled.values()) { + if (source.npmSpec === targetValue) { + return source; + } + } + return undefined; + }, + resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSources(...args), +})); + vi.mock("../../plugins/loader.js", () => ({ loadOpenClawPlugins: vi.fn(), })); @@ -41,6 +74,7 @@ const baseEntry: ChannelPluginCatalogEntry = { beforeEach(() => { vi.clearAllMocks(); + resolveBundledPluginSources.mockReturnValue(new Map()); }); function mockRepoLocalPathExists() { @@ -136,6 +170,45 @@ describe("ensureOnboardingPluginInstalled", () => { expect(await runInitialValueForChannel("beta")).toBe("npm"); }); + it("defaults to bundled local path on beta channel when available", async () => { + const runtime = makeRuntime(); + const select = vi.fn((async () => "skip" as T) as WizardPrompter["select"]); + const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] }); + const cfg: OpenClawConfig = { update: { channel: "beta" } }; + vi.mocked(fs.existsSync).mockReturnValue(false); + resolveBundledPluginSources.mockReturnValue( + new Map([ + [ + "zalo", + { + pluginId: "zalo", + localPath: "/opt/openclaw/extensions/zalo", + npmSpec: "@openclaw/zalo", + }, + ], + ]), + ); + + await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "local", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "local", + hint: "/opt/openclaw/extensions/zalo", + }), + ]), + }), + ); + }); + it("falls back to local path after npm install failure", async () => { const runtime = makeRuntime(); const note = vi.fn(async () => {}); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 54a23c297938a..14245461e21d2 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -2,8 +2,13 @@ import fs from "node:fs"; import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; +import { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + findBundledPluginSourceInMap, + resolveBundledPluginSources, +} from "../../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../../plugins/enable.js"; import { installPluginFromNpmSpec } from "../../plugins/install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js"; @@ -107,8 +112,12 @@ function resolveInstallDefaultChoice(params: { cfg: OpenClawConfig; entry: ChannelPluginCatalogEntry; localPath?: string | null; + bundledLocalPath?: string | null; }): InstallChoice { - const { cfg, entry, localPath } = params; + const { cfg, entry, localPath, bundledLocalPath } = params; + if (bundledLocalPath) { + return "local"; + } const updateChannel = cfg.update?.channel; if (updateChannel === "dev") { return localPath ? "local" : "npm"; @@ -136,11 +145,20 @@ export async function ensureOnboardingPluginInstalled(params: { const { entry, prompter, runtime, workspaceDir } = params; let next = params.cfg; const allowLocal = hasGitWorkspace(workspaceDir); - const localPath = resolveLocalPath(entry, workspaceDir, allowLocal); + const bundledSources = resolveBundledPluginSources({ workspaceDir }); + const bundledLocalPath = + resolveBundledInstallPlanForCatalogEntry({ + pluginId: entry.id, + npmSpec: entry.install.npmSpec, + findBundledSource: (lookup) => + findBundledPluginSourceInMap({ bundled: bundledSources, lookup }), + })?.bundledSource.localPath ?? null; + const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal); const defaultChoice = resolveInstallDefaultChoice({ cfg: next, entry, localPath, + bundledLocalPath, }); const choice = await promptInstallChoice({ entry, diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 7aace6f62786f..691dec466fdaa 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { findBundledPluginSource, resolveBundledPluginSources } from "./bundled-sources.js"; +import { + findBundledPluginSource, + findBundledPluginSourceInMap, + resolveBundledPluginSources, +} from "./bundled-sources.js"; const discoverOpenClawPluginsMock = vi.fn(); const loadPluginManifestMock = vi.fn(); @@ -124,4 +128,34 @@ describe("bundled plugin sources", () => { expect(resolved?.localPath).toBe("/app/extensions/diffs"); expect(missing).toBeUndefined(); }); + + it("reuses a pre-resolved bundled map for repeated lookups", () => { + const bundled = new Map([ + [ + "feishu", + { + pluginId: "feishu", + localPath: "/app/extensions/feishu", + npmSpec: "@openclaw/feishu", + }, + ], + ]); + + expect( + findBundledPluginSourceInMap({ + bundled, + lookup: { kind: "pluginId", value: "feishu" }, + }), + ).toEqual({ + pluginId: "feishu", + localPath: "/app/extensions/feishu", + npmSpec: "@openclaw/feishu", + }); + expect( + findBundledPluginSourceInMap({ + bundled, + lookup: { kind: "npmSpec", value: "@openclaw/feishu" }, + })?.pluginId, + ).toBe("feishu"); + }); }); diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index 4814246e1a4a2..a011227c278fe 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -11,6 +11,25 @@ export type BundledPluginLookup = | { kind: "npmSpec"; value: string } | { kind: "pluginId"; value: string }; +export function findBundledPluginSourceInMap(params: { + bundled: ReadonlyMap; + lookup: BundledPluginLookup; +}): BundledPluginSource | undefined { + const targetValue = params.lookup.value.trim(); + if (!targetValue) { + return undefined; + } + if (params.lookup.kind === "pluginId") { + return params.bundled.get(targetValue); + } + for (const source of params.bundled.values()) { + if (source.npmSpec === targetValue) { + return source; + } + } + return undefined; +} + export function resolveBundledPluginSources(params: { workspaceDir?: string; }): Map { @@ -49,18 +68,9 @@ export function findBundledPluginSource(params: { lookup: BundledPluginLookup; workspaceDir?: string; }): BundledPluginSource | undefined { - const targetValue = params.lookup.value.trim(); - if (!targetValue) { - return undefined; - } const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); - if (params.lookup.kind === "pluginId") { - return bundled.get(targetValue); - } - for (const source of bundled.values()) { - if (source.npmSpec === targetValue) { - return source; - } - } - return undefined; + return findBundledPluginSourceInMap({ + bundled, + lookup: params.lookup, + }); } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 07e1dc35969ab..07a2b6555d770 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const installPluginFromNpmSpecMock = vi.fn(); +const resolveBundledPluginSourcesMock = vi.fn(); vi.mock("./install.js", () => ({ installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args), @@ -10,9 +11,14 @@ vi.mock("./install.js", () => ({ }, })); +vi.mock("./bundled-sources.js", () => ({ + resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args), +})); + describe("updateNpmInstalledPlugins", () => { beforeEach(() => { installPluginFromNpmSpecMock.mockReset(); + resolveBundledPluginSourcesMock.mockReset(); }); it("skips integrity drift checks for unpinned npm specs during dry-run updates", async () => { @@ -151,3 +157,92 @@ describe("updateNpmInstalledPlugins", () => { ]); }); }); + +describe("syncPluginsForUpdateChannel", () => { + beforeEach(() => { + installPluginFromNpmSpecMock.mockReset(); + resolveBundledPluginSourcesMock.mockReset(); + }); + + it("keeps bundled path installs on beta without reinstalling from npm", async () => { + resolveBundledPluginSourcesMock.mockReturnValue( + new Map([ + [ + "feishu", + { + pluginId: "feishu", + localPath: "/app/extensions/feishu", + npmSpec: "@openclaw/feishu", + }, + ], + ]), + ); + + const { syncPluginsForUpdateChannel } = await import("./update.js"); + const result = await syncPluginsForUpdateChannel({ + channel: "beta", + config: { + plugins: { + load: { paths: ["/app/extensions/feishu"] }, + installs: { + feishu: { + source: "path", + sourcePath: "/app/extensions/feishu", + installPath: "/app/extensions/feishu", + spec: "@openclaw/feishu", + }, + }, + }, + }, + }); + + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(result.changed).toBe(false); + expect(result.summary.switchedToNpm).toEqual([]); + expect(result.config.plugins?.load?.paths).toEqual(["/app/extensions/feishu"]); + expect(result.config.plugins?.installs?.feishu?.source).toBe("path"); + }); + + it("repairs bundled install metadata when the load path is re-added", async () => { + resolveBundledPluginSourcesMock.mockReturnValue( + new Map([ + [ + "feishu", + { + pluginId: "feishu", + localPath: "/app/extensions/feishu", + npmSpec: "@openclaw/feishu", + }, + ], + ]), + ); + + const { syncPluginsForUpdateChannel } = await import("./update.js"); + const result = await syncPluginsForUpdateChannel({ + channel: "beta", + config: { + plugins: { + load: { paths: [] }, + installs: { + feishu: { + source: "path", + sourcePath: "/app/extensions/feishu", + installPath: "/tmp/old-feishu", + spec: "@openclaw/feishu", + }, + }, + }, + }, + }); + + expect(result.changed).toBe(true); + expect(result.config.plugins?.load?.paths).toEqual(["/app/extensions/feishu"]); + expect(result.config.plugins?.installs?.feishu).toMatchObject({ + source: "path", + sourcePath: "/app/extensions/feishu", + installPath: "/app/extensions/feishu", + spec: "@openclaw/feishu", + }); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 553867425a926..a17c34b90b817 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -459,42 +459,26 @@ export async function syncPluginsForUpdateChannel(params: { if (!pathsEqual(record.sourcePath, bundledInfo.localPath)) { continue; } - - const spec = record.spec ?? bundledInfo.npmSpec; - if (!spec) { - summary.warnings.push(`Missing npm spec for ${pluginId}; keeping local path.`); - continue; - } - - let result: Awaited>; - try { - result = await installPluginFromNpmSpec({ - spec, - mode: "update", - expectedPluginId: pluginId, - logger: params.logger, - }); - } catch (err) { - summary.errors.push(`Failed to install ${pluginId}: ${String(err)}`); - continue; - } - if (!result.ok) { - summary.errors.push(`Failed to install ${pluginId}: ${result.error}`); + // Keep explicit bundled installs on release channels. Replacing them with + // npm installs can reintroduce duplicate-id shadowing and packaging drift. + loadHelpers.addPath(bundledInfo.localPath); + const alreadyBundled = + record.source === "path" && + pathsEqual(record.sourcePath, bundledInfo.localPath) && + pathsEqual(record.installPath, bundledInfo.localPath); + if (alreadyBundled) { continue; } next = recordPluginInstall(next, { pluginId, - source: "npm", - spec, - installPath: result.targetDir, - version: result.version, - ...buildNpmResolutionInstallFields(result.npmResolution), - sourcePath: undefined, + source: "path", + sourcePath: bundledInfo.localPath, + installPath: bundledInfo.localPath, + spec: record.spec ?? bundledInfo.npmSpec, + version: record.version, }); - summary.switchedToNpm.push(pluginId); changed = true; - loadHelpers.removePath(bundledInfo.localPath); } } From 2970d72554512b27b80d7dfbc82b40d034f81d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi?= Date: Sun, 8 Mar 2026 19:06:21 +0100 Subject: [PATCH 083/311] docs: update Brave Search API docs for Feb 2026 plan restructuring (#40111) Merged via squash. Prepared head SHA: c651f07855a2e57bb2a25ee42ec62b4bad5b6d8c Co-authored-by: remusao <1299873+remusao@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/brave-search.md | 8 ++++---- docs/reference/api-usage-costs.md | 7 ++++++- docs/tools/web.md | 8 ++++++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93fbf78154ca..203cb9927df6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. +- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. ### Fixes diff --git a/docs/brave-search.md b/docs/brave-search.md index 3b1e55c0aae0f..a8bba5c3e91bd 100644 --- a/docs/brave-search.md +++ b/docs/brave-search.md @@ -13,7 +13,7 @@ OpenClaw supports Brave Search API as a `web_search` provider. ## Get an API key 1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/) -2. In the dashboard, choose the **Data for Search** plan and generate an API key. +2. In the dashboard, choose the **Search** plan and generate an API key. 3. Store the key in config or set `BRAVE_API_KEY` in the Gateway environment. ## Config example @@ -72,9 +72,9 @@ await web_search({ ## Notes -- The Data for AI plan is **not** compatible with `web_search`. -- Brave provides paid plans; check the Brave API portal for current limits. -- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel. +- OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits. +- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans. +- The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service). - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`). See [Web tools](/tools/web) for the full web_search configuration. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 28ead36b0c1a7..dba017aacc171 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -79,11 +79,16 @@ See [Memory](/concepts/memory). `web_search` uses API keys and may incur usage charges depending on your provider: -- **Perplexity Search API**: `PERPLEXITY_API_KEY` - **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey` - **Gemini (Google Search)**: `GEMINI_API_KEY` - **Grok (xAI)**: `XAI_API_KEY` - **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY` +- **Perplexity Search API**: `PERPLEXITY_API_KEY` + +**Brave Search free credit:** Each Brave plan includes $5/month in renewing +free credit. The Search plan costs $5 per 1,000 requests, so the credit covers +1,000 requests/month at no charge. Set your usage limit in the Brave dashboard +to avoid unexpected charges. See [Web tools](/tools/web). diff --git a/docs/tools/web.md b/docs/tools/web.md index 94732105098b1..c25deff970cc7 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -56,10 +56,14 @@ Use `openclaw configure --section web` to set up your API key and choose a provi ### Brave Search 1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/) -2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key. +2. In the dashboard, choose the **Search** plan and generate an API key. 3. Run `openclaw configure --section web` to store the key in config, or set `BRAVE_API_KEY` in your environment. -Brave provides paid plans; check the Brave API portal for the current limits and pricing. +Each Brave plan includes **$5/month in free credit** (renewing). The Search +plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set +your usage limit in the Brave dashboard to avoid unexpected charges. See the +[Brave API portal](https://brave.com/search/api/) for current plans and +pricing. ### Perplexity Search From bdf9739e592c539ce905405f2272d56b7d95bcd2 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:11:40 -0500 Subject: [PATCH 084/311] Add too-many-prs override label handling --- .github/workflows/auto-response.yml | 6 ++++++ .github/workflows/labeler.yml | 28 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 8fb76b99b9e1d..a40149b7ccbdd 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -261,6 +261,8 @@ jobs: }; const triggerLabel = "trigger-response"; + const activePrLimitLabel = "r: too-many-prs"; + const activePrLimitOverrideLabel = "r: too-many-prs-override"; const target = context.payload.issue ?? context.payload.pull_request; if (!target) { return; @@ -448,6 +450,10 @@ jobs: return; } + if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) { + labelSet.delete(activePrLimitLabel); + } + const rule = rules.find((item) => labelSet.has(item.label)); if (!rule) { return; diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 2e8e1ec59b09d..8de54a416f825 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -213,6 +213,7 @@ jobs: } const activePrLimitLabel = "r: too-many-prs"; + const activePrLimitOverrideLabel = "r: too-many-prs-override"; const activePrLimit = 10; const labelColor = "B60205"; const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`; @@ -221,12 +222,37 @@ jobs: return; } + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + per_page: 100, + }); + const labelNames = new Set( - (pullRequest.labels ?? []) + currentLabels .map((label) => (typeof label === "string" ? label : label?.name)) .filter((name) => typeof name === "string"), ); + if (labelNames.has(activePrLimitOverrideLabel)) { + if (labelNames.has(activePrLimitLabel)) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + name: activePrLimitLabel, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + } + } + return; + } + const ensureLabelExists = async () => { try { await github.rest.issues.getLabel({ From 5387faa7181fb4458fc30f68955d4443339824c8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:08:34 -0700 Subject: [PATCH 085/311] CI: satisfy provider merge fixture typing --- src/agents/models-config.merge.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index b76b3509e2614..5e0483fdb593a 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -8,6 +8,8 @@ import { import type { ProviderConfig } from "./models-config.providers.js"; describe("models-config merge helpers", () => { + const preservedApiKey = "AGENT_KEY"; // pragma: allowlist secret + it("refreshes implicit model metadata while preserving explicit reasoning overrides", () => { const merged = mergeProviderModels( { @@ -75,7 +77,7 @@ describe("models-config merge helpers", () => { existingProviders: { custom: { baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", + apiKey: preservedApiKey, models: [{ id: "model", api: "openai-completions" }], } as ExistingProviderConfig, }, @@ -85,7 +87,7 @@ describe("models-config merge helpers", () => { expect(merged.custom).toEqual( expect.objectContaining({ - apiKey: "AGENT_KEY", + apiKey: preservedApiKey, baseUrl: "https://config.example/v1", }), ); From f19761cefa129c93c2f92c2f6cc77179dda6c96b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:08:49 -0700 Subject: [PATCH 086/311] Tests: reduce web search secret-scan noise --- src/agents/tools/web-search.test.ts | 51 ++++++++++++++++++----------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 3c9134284a5c9..4a7b002d7846f 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -27,6 +27,11 @@ const { const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_"); +const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_"); +const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_"); +const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-"); +const directPerplexityApiKey = ["pplx", "test"].join("-"); +const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-"); describe("web_search perplexity compatibility routing", () => { it("detects API key prefixes", () => { @@ -42,27 +47,33 @@ describe("web_search perplexity compatibility routing", () => { }); it("resolves OpenRouter env auth and transport", () => { - withEnv({ PERPLEXITY_API_KEY: undefined, OPENROUTER_API_KEY: "sk-or-v1-test" }, () => { - expect(resolvePerplexityApiKey(undefined)).toEqual({ - apiKey: "sk-or-v1-test", - source: "openrouter_env", - }); - expect(resolvePerplexityTransport(undefined)).toMatchObject({ - baseUrl: "https://openrouter.ai/api/v1", - model: "perplexity/sonar-pro", - transport: "chat_completions", - }); - }); + withEnv( + { [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey }, + () => { + expect(resolvePerplexityApiKey(undefined)).toEqual({ + apiKey: openRouterPerplexityApiKey, + source: "openrouter_env", + }); + expect(resolvePerplexityTransport(undefined)).toMatchObject({ + baseUrl: "https://openrouter.ai/api/v1", + model: "perplexity/sonar-pro", + transport: "chat_completions", + }); + }, + ); }); it("uses native Search API for direct Perplexity when no legacy overrides exist", () => { - withEnv({ PERPLEXITY_API_KEY: "pplx-test", OPENROUTER_API_KEY: undefined }, () => { - expect(resolvePerplexityTransport(undefined)).toMatchObject({ - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-pro", - transport: "search_api", - }); - }); + withEnv( + { [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined }, + () => { + expect(resolvePerplexityTransport(undefined)).toMatchObject({ + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + transport: "search_api", + }); + }, + ); }); it("switches direct Perplexity to chat completions when model override is configured", () => { @@ -71,7 +82,7 @@ describe("web_search perplexity compatibility routing", () => { ); expect( resolvePerplexityTransport({ - apiKey: "pplx-test", + apiKey: directPerplexityApiKey, model: "perplexity/sonar-reasoning-pro", }), ).toMatchObject({ @@ -84,7 +95,7 @@ describe("web_search perplexity compatibility routing", () => { it("treats unrecognized configured keys as direct Perplexity by default", () => { expect( resolvePerplexityTransport({ - apiKey: "enterprise-perplexity-test", + apiKey: enterprisePerplexityApiKey, }), ).toMatchObject({ baseUrl: "https://api.perplexity.ai", From 6f4de3cc23c29d1db3d8e65814f048ce07e81460 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:08:56 -0700 Subject: [PATCH 087/311] Web search: rename Perplexity auth source helper --- 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 b4ef4b8348496..72ab3cc9156fc 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -667,8 +667,8 @@ function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHin function resolvePerplexityBaseUrl( perplexity?: PerplexityConfig, - apiKeySource: PerplexityApiKeySource = "none", - apiKey?: string, + authSource: PerplexityApiKeySource = "none", + configuredKey?: string, ): string { const fromConfig = perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" @@ -677,14 +677,14 @@ function resolvePerplexityBaseUrl( if (fromConfig) { return fromConfig; } - if (apiKeySource === "perplexity_env") { + if (authSource === "perplexity_env") { return PERPLEXITY_DIRECT_BASE_URL; } - if (apiKeySource === "openrouter_env") { + if (authSource === "openrouter_env") { return DEFAULT_PERPLEXITY_BASE_URL; } - if (apiKeySource === "config") { - const inferred = inferPerplexityBaseUrlFromApiKey(apiKey); + if (authSource === "config") { + const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey); if (inferred === "openrouter") { return DEFAULT_PERPLEXITY_BASE_URL; } From 615466bdf42cbe842c27c854f59cf89c77303e6f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:09:05 -0700 Subject: [PATCH 088/311] Docs: use placeholder OpenRouter key in Perplexity guide --- docs/perplexity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/perplexity.md b/docs/perplexity.md index 9259651569f3b..bb1acef49c851 100644 --- a/docs/perplexity.md +++ b/docs/perplexity.md @@ -57,7 +57,7 @@ Optional legacy controls: search: { provider: "perplexity", perplexity: { - apiKey: "sk-or-v1-...", + apiKey: "", baseUrl: "https://openrouter.ai/api/v1", model: "perplexity/sonar-pro", }, From 55465d86d93d4026b74f6dd3621618be45124988 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:09:12 -0700 Subject: [PATCH 089/311] Docs: use placeholder OpenRouter key in web tool docs --- docs/tools/web.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/web.md b/docs/tools/web.md index c25deff970cc7..25cb5d7f4f069 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -150,7 +150,7 @@ In this mode, `country` and `language` / `search_lang` still work, but `ui_lang` enabled: true, provider: "perplexity", perplexity: { - apiKey: "sk-or-v1-...", // optional if OPENROUTER_API_KEY is set + apiKey: "", // optional if OPENROUTER_API_KEY is set baseUrl: "https://openrouter.ai/api/v1", model: "perplexity/sonar-pro", }, From 2ae58542a02d6cbf623815645fe15fc34fe31f50 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:09:18 -0700 Subject: [PATCH 090/311] Fixtures: normalize talk config API key placeholder --- test-fixtures/talk-config-contract.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-fixtures/talk-config-contract.json b/test-fixtures/talk-config-contract.json index 8877c189385a1..ed3a7a4605514 100644 --- a/test-fixtures/talk-config-contract.json +++ b/test-fixtures/talk-config-contract.json @@ -81,7 +81,7 @@ }, "talk": { "voiceId": "voice-legacy", - "apiKey": "legacy-key" + "apiKey": "xxxxx" } } ] From d23d36a2f9c2ca25ec60151807514ef31b11af9b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:09:22 -0700 Subject: [PATCH 091/311] Tests: lower entropy git commit fixtures --- src/infra/git-commit.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 20c7238391c1e..5f4e7549ca718 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -361,10 +361,10 @@ describe("git commit resolution", () => { await makeFakeGitRepo(validRepo, { head: "ref: refs/heads/main\n", refs: { - "refs/heads/main": "fedcba9876543210fedcba9876543210fedcba98", + "refs/heads/main": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }, }); - expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("fedcba9"); + expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("aaaaaaa"); }); it("resolves refs from the git commondir in worktree layouts", async () => { @@ -375,7 +375,7 @@ describe("git commit resolution", () => { await fs.mkdir(commonGitDir, { recursive: true }); const refPath = path.join(commonGitDir, "refs", "heads", "main"); await fs.mkdir(path.dirname(refPath), { recursive: true }); - await fs.writeFile(refPath, "76543210fedcba9876543210fedcba9876543210\n", "utf-8"); + await fs.writeFile(refPath, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n", "utf-8"); await makeFakeGitRepo(repoRoot, { gitdir: worktreeGitDir, head: "ref: refs/heads/main\n", @@ -384,7 +384,7 @@ describe("git commit resolution", () => { const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("7654321"); + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("bbbbbbb"); }); it("reads full HEAD refs before parsing long branch names", async () => { @@ -394,12 +394,12 @@ describe("git commit resolution", () => { await makeFakeGitRepo(repoRoot, { head: `ref: ${longRefName}\n`, refs: { - [longRefName]: "0123456789abcdef0123456789abcdef01234567", + [longRefName]: "cccccccccccccccccccccccccccccccccccccccc", }, }); const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("0123456"); + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("ccccccc"); }); }); From e19b3679d152f43e7e6e17289f42685ec2ecd858 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:09:29 -0700 Subject: [PATCH 092/311] Chore: widen xxxxx detect-secrets allowlist --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6fcc25e727918..95421eb071ded 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -66,7 +66,7 @@ repos: - --exclude-lines - 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},' - --exclude-lines - - '"ap[i]Key": "xxxxx",' + - '"ap[i]Key": "xxxxx"(,)?' - --exclude-lines - 'ap[i]Key: "A[I]za\.\.\.",' # Shell script linting From aebfce7a362d8e737612e5363854fbed9f887540 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:09:32 -0700 Subject: [PATCH 093/311] Chore: refresh detect-secrets baseline --- .secrets.baseline | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 697214ab1ecd3..9455078b2d389 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -9972,7 +9972,7 @@ "filename": "docs/perplexity.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 29 + "line_number": 43 } ], "docs/plugins/voice-call.md": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T14:28:30Z" + "generated_at": "2026-03-08T17:49:19Z" } From 7856f5730c28a60413a926c116a0abaa01d01182 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:11:00 -0700 Subject: [PATCH 094/311] Web search: allowlist Perplexity auth source type name --- src/agents/tools/web-search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 72ab3cc9156fc..1501063a9cf2e 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -667,7 +667,7 @@ function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHin function resolvePerplexityBaseUrl( perplexity?: PerplexityConfig, - authSource: PerplexityApiKeySource = "none", + authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret configuredKey?: string, ): string { const fromConfig = From 3b68d3fded191fdab708863f3e42a35821a9baf1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:11:10 -0700 Subject: [PATCH 095/311] Chore: refresh detect-secrets baseline after docs line changes --- .secrets.baseline | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 9455078b2d389..ee4432bb80a6f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -151,7 +151,7 @@ "export CUSTOM_API_K[E]Y=\"your-key\"", "grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'", "env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},", - "\"ap[i]Key\": \"xxxxx\",", + "\"ap[i]Key\": \"xxxxx\"(,)?", "ap[i]Key: \"A[I]za\\.\\.\\.\"," ] }, @@ -10198,21 +10198,21 @@ "filename": "docs/tools/web.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 129 + "line_number": 131 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 202 + "line_number": 224 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 303 + "line_number": 328 } ], "docs/tts.md": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T17:49:19Z" + "generated_at": "2026-03-08T18:10:19Z" } From c5bba6628e51a6a35bbd10378a5dc92e0a437403 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:13:06 -0700 Subject: [PATCH 096/311] Chore: refresh detect-secrets baseline after final scan --- .secrets.baseline | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index ee4432bb80a6f..4e2e295263741 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -10255,14 +10255,14 @@ "filename": "docs/zh-CN/channels/feishu.md", "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", "is_verified": false, - "line_number": 195 + "line_number": 191 }, { "type": "Secret Keyword", "filename": "docs/zh-CN/channels/feishu.md", "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", "is_verified": false, - "line_number": 509 + "line_number": 505 } ], "docs/zh-CN/channels/line.md": [ @@ -11680,7 +11680,7 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 266 + "line_number": 292 } ], "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T18:10:19Z" + "generated_at": "2026-03-08T18:12:38Z" } From 4c71176c9f32c24d8fd2b8cdfd931c3c6a0e38dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:15:03 -0700 Subject: [PATCH 097/311] Chore: refresh detect-secrets baseline for Feishu docs --- .secrets.baseline | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4e2e295263741..33aa3b6cecdb0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -9619,14 +9619,14 @@ "filename": "docs/channels/feishu.md", "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", "is_verified": false, - "line_number": 189 + "line_number": 187 }, { "type": "Secret Keyword", "filename": "docs/channels/feishu.md", "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", "is_verified": false, - "line_number": 501 + "line_number": 499 } ], "docs/channels/irc.md": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T18:12:38Z" + "generated_at": "2026-03-08T18:14:00Z" } From 0b452a5665f4353ee00a98c7475026c35b6a80ba Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:17:15 -0700 Subject: [PATCH 098/311] CLI: set local gateway mode in setup --- src/commands/setup.test.ts | 60 ++++++++++++++++++++++++++++++++++++++ src/commands/setup.ts | 20 +++++++++++-- 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/commands/setup.test.ts diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts new file mode 100644 index 0000000000000..c72850d08b08d --- /dev/null +++ b/src/commands/setup.test.ts @@ -0,0 +1,60 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import { setupCommand } from "./setup.js"; + +describe("setupCommand", () => { + it("writes gateway.mode=local on first run", async () => { + await withTempHome(async (home) => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await setupCommand(undefined, runtime); + + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const raw = await fs.readFile(configPath, "utf-8"); + + expect(raw).toContain('"mode": "local"'); + expect(raw).toContain('"workspace"'); + }); + }); + + it("adds gateway.mode=local to an existing config without overwriting workspace", async () => { + await withTempHome(async (home) => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const workspace = path.join(home, "custom-workspace"); + + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + defaults: { + workspace, + }, + }, + }), + ); + + await setupCommand(undefined, runtime); + + const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + agents?: { defaults?: { workspace?: string } }; + gateway?: { mode?: string }; + }; + + expect(raw.agents?.defaults?.workspace).toBe(workspace); + expect(raw.gateway?.mode).toBe("local"); + }); + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 3045f748b19ce..007e83af3394b 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -50,14 +50,30 @@ export async function setupCommand( workspace, }, }, + gateway: { + ...cfg.gateway, + mode: cfg.gateway?.mode ?? "local", + }, }; - if (!existingRaw.exists || defaults.workspace !== workspace) { + if ( + !existingRaw.exists || + defaults.workspace !== workspace || + cfg.gateway?.mode !== next.gateway?.mode + ) { await writeConfigFile(next); if (!existingRaw.exists) { runtime.log(`Wrote ${formatConfigPath(configPath)}`); } else { - logConfigUpdated(runtime, { path: configPath, suffix: "(set agents.defaults.workspace)" }); + const updates: string[] = []; + if (defaults.workspace !== workspace) { + updates.push("set agents.defaults.workspace"); + } + if (cfg.gateway?.mode !== next.gateway?.mode) { + updates.push("set gateway.mode"); + } + const suffix = updates.length > 0 ? `(${updates.join(", ")})` : undefined; + logConfigUpdated(runtime, { path: configPath, suffix }); } } else { runtime.log(`Config OK: ${formatConfigPath(configPath)}`); From efcca3d2ea53b19ce7a5ec0b02c8aa1556b39235 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 11:22:41 -0700 Subject: [PATCH 099/311] Tests: format daemon lifecycle CLI coverage --- src/cli/daemon-cli/lifecycle.test.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 3f0ed6d531c30..f1e87fc4938c0 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = - vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> - >(); +const probeGateway = vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> +>(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); From e5c06dd64a167b5e6135ea7356427332ac17e596 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:23:41 +0000 Subject: [PATCH 100/311] refactor: use model compat for anthropic tool payload normalization --- src/agents/models-config.providers.ts | 3 + .../pi-embedded-runner-extraparams.test.ts | 51 ++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 58 ++++++++++++++----- src/config/types.models.ts | 1 + 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 8ae03757beab4..56dd6b5d948e1 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -837,6 +837,9 @@ export function buildKimiCodingProvider(): ProviderConfig { cost: KIMI_CODING_DEFAULT_COST, contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, + compat: { + requiresOpenAiAnthropicToolPayload: true, + }, }, ], }; diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 9755367575972..fb3569369caea 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -880,6 +880,57 @@ describe("applyExtraParamsToAgent", () => { ]); }); + it("uses explicit compat metadata for anthropic tool payload normalization", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { + tools: [ + { + name: "read", + description: "Read file", + input_schema: { type: "object", properties: {} }, + }, + ], + }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + undefined, + "custom-anthropic-proxy", + "proxy-model", + undefined, + "low", + ); + + const model = { + api: "anthropic-messages", + provider: "custom-anthropic-proxy", + id: "proxy-model", + compat: { + requiresOpenAiAnthropicToolPayload: true, + }, + } as Model<"anthropic-messages">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.tools).toEqual([ + { + type: "function", + function: { + name: "read", + description: "Read file", + parameters: { type: "object", properties: {} }, + }, + }, + ]); + }); + it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 7ac14f9ee9831..23178e1d1fa9d 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -794,7 +794,7 @@ function createMoonshotThinkingWrapper( function requiresAnthropicToolPayloadCompatibilityForModel(model: { api?: unknown; provider?: unknown; - baseUrl?: unknown; + compat?: unknown; }): boolean { if (model.api !== "anthropic-messages") { return false; @@ -807,19 +807,49 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: { return true; } - if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) { + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { return false; } - try { - const parsed = new URL(model.baseUrl); - const host = parsed.hostname.toLowerCase(); - const pathname = parsed.pathname.toLowerCase(); - return host.endsWith("kimi.com") && pathname.startsWith("/coding"); - } catch { - const normalized = model.baseUrl.toLowerCase(); - return normalized.includes("kimi.com/coding"); + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + +function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { + provider?: unknown; + compat?: unknown; +}): boolean { + if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { + return true; + } + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; } + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + +function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { + provider?: unknown; + compat?: unknown; +}): boolean { + if ( + typeof model.provider === "string" && + usesOpenAiStringModeAnthropicToolChoice(model.provider) + ) { + return true; + } + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; + } + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); } function normalizeOpenAiFunctionAnthropicToolDefinition( @@ -903,19 +933,21 @@ function createAnthropicToolPayloadCompatibilityWrapper( return underlying(model, context, { ...options, onPayload: (payload) => { - const provider = typeof model.provider === "string" ? model.provider : undefined; if ( payload && typeof payload === "object" && requiresAnthropicToolPayloadCompatibilityForModel(model) ) { const payloadObj = payload as Record; - if (Array.isArray(payloadObj.tools) && usesOpenAiFunctionAnthropicToolSchema(provider)) { + if ( + Array.isArray(payloadObj.tools) && + usesOpenAiFunctionAnthropicToolSchemaForModel(model) + ) { payloadObj.tools = payloadObj.tools .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) .filter((tool): tool is Record => !!tool); } - if (usesOpenAiStringModeAnthropicToolChoice(provider)) { + if (usesOpenAiStringModeAnthropicToolChoiceForModel(model)) { payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( payloadObj.tool_choice, ); diff --git a/src/config/types.models.ts b/src/config/types.models.ts index b881269d9619b..f244c9d0658ce 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -26,6 +26,7 @@ export type ModelCompatConfig = { requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresMistralToolIds?: boolean; + requiresOpenAiAnthropicToolPayload?: boolean; }; export type ModelProviderAuthMode = "api-key" | "aws-sdk" | "oauth" | "token"; From 64d4d9aabb61984c4384c2dfe93a3a74a9180411 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:25:56 +0000 Subject: [PATCH 101/311] refactor: move bundled extension gap allowlists into manifests --- extensions/googlechat/package.json | 5 +++++ extensions/matrix/package.json | 7 +++++++ extensions/msteams/package.json | 5 +++++ extensions/nostr/package.json | 5 +++++ extensions/tlon/package.json | 7 +++++++ extensions/zalouser/package.json | 5 +++++ scripts/release-check.ts | 14 ++++---------- test/release-check.test.ts | 21 ++++++++++++++++++--- 8 files changed, 56 insertions(+), 13 deletions(-) diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 883af1bd876e0..1a7a876b4ef30 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -37,6 +37,11 @@ "npmSpec": "@openclaw/googlechat", "localPath": "extensions/googlechat", "defaultChoice": "npm" + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "google-auth-library" + ] } } } diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 8970cd77d3cf0..f32e8915436ce 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -29,6 +29,13 @@ "npmSpec": "@openclaw/matrix", "localPath": "extensions/matrix", "defaultChoice": "npm" + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@matrix-org/matrix-sdk-crypto-nodejs", + "@vector-im/matrix-bot-sdk", + "music-metadata" + ] } } } diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 8bb3fd0938f39..0415203ff0def 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -27,6 +27,11 @@ "npmSpec": "@openclaw/msteams", "localPath": "extensions/msteams", "defaultChoice": "npm" + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@microsoft/agents-hosting" + ] } } } diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 294a254b69ac9..389d7b210ffa3 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -25,6 +25,11 @@ "npmSpec": "@openclaw/nostr", "localPath": "extensions/nostr", "defaultChoice": "npm" + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "nostr-tools" + ] } } } diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index e1173e3b2f15d..9fe4d84353a17 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -27,6 +27,13 @@ "npmSpec": "@openclaw/tlon", "localPath": "extensions/tlon", "defaultChoice": "npm" + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "@tloncorp/api", + "@tloncorp/tlon-skill", + "@urbit/aura" + ] } } } diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index e957662f07ced..217653508dbec 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -29,6 +29,11 @@ "npmSpec": "@openclaw/zalouser", "localPath": "extensions/zalouser", "defaultChoice": "npm" + }, + "releaseChecks": { + "rootDependencyMirrorAllowlist": [ + "zca-js" + ] } } } diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 095f5f2a34be9..db396672ff77f 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -17,6 +17,9 @@ type PackageJson = { install?: { npmSpec?: string; }; + releaseChecks?: { + rootDependencyMirrorAllowlist?: string[]; + }; }; }; @@ -128,15 +131,6 @@ function normalizePluginSyncVersion(version: string): string { return normalized.replace(/[-+].*$/, ""); } -const ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS: Record = { - googlechat: ["google-auth-library"], - matrix: ["@matrix-org/matrix-sdk-crypto-nodejs", "@vector-im/matrix-bot-sdk", "music-metadata"], - msteams: ["@microsoft/agents-hosting"], - nostr: ["nostr-tools"], - tlon: ["@tloncorp/api", "@tloncorp/tlon-skill", "@urbit/aura"], - zalouser: ["zca-js"], -}; - export function collectBundledExtensionRootDependencyGapErrors(params: { rootPackage: PackageJson; extensions: Array<{ id: string; packageJson: PackageJson }>; @@ -156,7 +150,7 @@ export function collectBundledExtensionRootDependencyGapErrors(params: { .filter((dep) => dep !== "openclaw" && !rootDeps[dep]) .toSorted(); const allowlisted = [ - ...(ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS[extension.id] ?? []), + ...(extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []), ].toSorted(); if (missing.join("\n") !== allowlisted.join("\n")) { const unexpected = missing.filter((dep) => !allowlisted.includes(dep)); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index e366623a886aa..8a9ec8fea0ef5 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -40,7 +40,12 @@ describe("collectBundledExtensionRootDependencyGapErrors", () => { id: "googlechat", packageJson: { dependencies: { "google-auth-library": "^1.0.0" }, - openclaw: { install: { npmSpec: "@openclaw/googlechat" } }, + openclaw: { + install: { npmSpec: "@openclaw/googlechat" }, + releaseChecks: { + rootDependencyMirrorAllowlist: ["google-auth-library"], + }, + }, }, }, { @@ -66,7 +71,12 @@ describe("collectBundledExtensionRootDependencyGapErrors", () => { id: "googlechat", packageJson: { dependencies: { "google-auth-library": "^1.0.0", undici: "^7.0.0" }, - openclaw: { install: { npmSpec: "@openclaw/googlechat" } }, + openclaw: { + install: { npmSpec: "@openclaw/googlechat" }, + releaseChecks: { + rootDependencyMirrorAllowlist: ["google-auth-library"], + }, + }, }, }, ], @@ -85,7 +95,12 @@ describe("collectBundledExtensionRootDependencyGapErrors", () => { id: "googlechat", packageJson: { dependencies: { "google-auth-library": "^1.0.0" }, - openclaw: { install: { npmSpec: "@openclaw/googlechat" } }, + openclaw: { + install: { npmSpec: "@openclaw/googlechat" }, + releaseChecks: { + rootDependencyMirrorAllowlist: ["google-auth-library"], + }, + }, }, }, ], From 749eb4efeadc9cb381dcfe99c54c47c39a709422 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:51:43 +0000 Subject: [PATCH 102/311] refactor: thread config runtime env through models config --- ...els-config.applies-config-env-vars.test.ts | 48 ++++++++++++++----- src/agents/models-config.providers.ts | 15 +++--- src/agents/models-config.ts | 15 +++--- src/config/config.env-vars.test.ts | 16 ++++++- src/config/env-vars.ts | 9 ++++ 5 files changed, 76 insertions(+), 27 deletions(-) diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 617e153f4b975..fa4adb861684d 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -1,7 +1,7 @@ +import fs from "node:fs/promises"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { - CUSTOM_PROXY_MODELS_CONFIG, installModelsConfigTestHooks, unsetEnv, withModelsTempHome as withTempHome, @@ -14,33 +14,55 @@ installModelsConfigTestHooks(); const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV"; describe("models-config", () => { - it("applies config env.vars entries while ensuring models.json", async () => { + it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => { await withTempHome(async () => { - await withTempEnv([TEST_ENV_VAR], async () => { - unsetEnv([TEST_ENV_VAR]); + await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => { + unsetEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR]); const cfg: OpenClawConfig = { - ...CUSTOM_PROXY_MODELS_CONFIG, - env: { vars: { [TEST_ENV_VAR]: "from-config" } }, + models: { providers: {} }, + env: { + vars: { + OPENROUTER_API_KEY: "from-config", + [TEST_ENV_VAR]: "from-config", + }, + }, }; - await ensureOpenClawModelsJson(cfg); + const { agentDir } = await ensureOpenClawModelsJson(cfg); - expect(process.env[TEST_ENV_VAR]).toBe("from-config"); + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + expect(process.env[TEST_ENV_VAR]).toBeUndefined(); + + const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as { + providers?: { openrouter?: { apiKey?: string } }; + }; + expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY"); }); }); }); - it("does not overwrite already-set host env vars", async () => { + it("does not overwrite already-set host env vars while ensuring models.json", async () => { await withTempHome(async () => { - await withTempEnv([TEST_ENV_VAR], async () => { + await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => { + process.env.OPENROUTER_API_KEY = "from-host"; process.env[TEST_ENV_VAR] = "from-host"; const cfg: OpenClawConfig = { - ...CUSTOM_PROXY_MODELS_CONFIG, - env: { vars: { [TEST_ENV_VAR]: "from-config" } }, + models: { providers: {} }, + env: { + vars: { + OPENROUTER_API_KEY: "from-config", + [TEST_ENV_VAR]: "from-config", + }, + }, }; - await ensureOpenClawModelsJson(cfg); + const { agentDir } = await ensureOpenClawModelsJson(cfg); + const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as { + providers?: { openrouter?: { apiKey?: string } }; + }; + expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY"); + expect(process.env.OPENROUTER_API_KEY).toBe("from-host"); expect(process.env[TEST_ENV_VAR]).toBe("from-host"); }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 56dd6b5d948e1..8aca2c26d0e95 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -415,8 +415,8 @@ function resolveEnvApiKeyVarName( return match ? match[1] : undefined; } -function resolveAwsSdkApiKeyVarName(): string { - return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE"; +function resolveAwsSdkApiKeyVarName(env: NodeJS.ProcessEnv = process.env): string { + return resolveAwsSdkEnvVarName(env) ?? "AWS_PROFILE"; } function normalizeHeaderValues(params: { @@ -603,6 +603,7 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig export function normalizeProviders(params: { providers: ModelsConfig["providers"]; agentDir: string; + env?: NodeJS.ProcessEnv; secretDefaults?: { env?: string; file?: string; @@ -614,6 +615,7 @@ export function normalizeProviders(params: { if (!providers) { return providers; } + const env = params.env ?? process.env; const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); @@ -646,6 +648,7 @@ export function normalizeProviders(params: { const profileApiKey = resolveApiKeyFromProfiles({ provider: normalizedKey, store: authStore, + env, }); if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { @@ -687,8 +690,8 @@ export function normalizeProviders(params: { currentApiKey.trim() && !ENV_VAR_NAME_RE.test(currentApiKey.trim()) ) { - const envVarName = resolveEnvApiKeyVarName(normalizedKey); - if (envVarName && process.env[envVarName] === currentApiKey) { + const envVarName = resolveEnvApiKeyVarName(normalizedKey, env); + if (envVarName && env[envVarName] === currentApiKey) { mutated = true; normalizedProvider = { ...normalizedProvider, apiKey: envVarName }; } @@ -704,11 +707,11 @@ export function normalizeProviders(params: { const authMode = normalizedProvider.auth ?? (normalizedKey === "amazon-bedrock" ? "aws-sdk" : undefined); if (authMode === "aws-sdk") { - const apiKey = resolveAwsSdkApiKeyVarName(); + const apiKey = resolveAwsSdkApiKeyVarName(env); mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } else { - const fromEnv = resolveEnvApiKeyVarName(normalizedKey); + const fromEnv = resolveEnvApiKeyVarName(normalizedKey, env); const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { if (profileApiKey && profileApiKey.source !== "plaintext") { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 9a470ee5cdcfd..8fa237fcaf3e1 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -6,7 +6,7 @@ import { type OpenClawConfig, loadConfig, } from "../config/config.js"; -import { applyConfigEnvVars } from "../config/env-vars.js"; +import { createConfigRuntimeEnv } from "../config/env-vars.js"; import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { @@ -46,12 +46,14 @@ async function readExistingModelsFile(pathname: string): Promise<{ async function resolveProvidersForModelsJson(params: { cfg: OpenClawConfig; agentDir: string; + env: NodeJS.ProcessEnv; }): Promise> { - const { cfg, agentDir } = params; + const { cfg, agentDir, env } = params; const explicitProviders = cfg.models?.providers ?? {}; const implicitProviders = await resolveImplicitProviders({ agentDir, config: cfg, + env, explicitProviders, }); const providers: Record = mergeProviders({ @@ -143,12 +145,10 @@ export async function ensureOpenClawModelsJson( return await withModelsJsonWriteLock(targetPath, async () => { // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are - // available in process.env before implicit provider discovery. Some - // callers (agent runner, tools) pass config objects that haven't gone - // through the full loadConfig() pipeline which applies these. - applyConfigEnvVars(cfg); + // are available to provider discovery without mutating process.env. + const env = createConfigRuntimeEnv(cfg); - const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); + const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env }); if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; @@ -170,6 +170,7 @@ export async function ensureOpenClawModelsJson( normalizeProviders({ providers, agentDir, + env, secretDefaults: cfg.secrets?.defaults, secretRefManagedProviders, }) ?? providers; diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index d292738794806..389edc6d11dcf 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -3,7 +3,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveConfigEnvVars } from "./env-substitution.js"; -import { applyConfigEnvVars, collectConfigRuntimeEnvVars } from "./env-vars.js"; +import { + applyConfigEnvVars, + collectConfigRuntimeEnvVars, + createConfigRuntimeEnv, +} from "./env-vars.js"; import { withEnvOverride, withTempHome } from "./test-helpers.js"; import type { OpenClawConfig } from "./types.js"; @@ -29,6 +33,16 @@ describe("config env vars", () => { }); }); + it("can build a merged runtime env without mutating process.env", async () => { + await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => { + const merged = createConfigRuntimeEnv({ + env: { vars: { OPENROUTER_API_KEY: "config-key" } }, + } as OpenClawConfig); + expect(merged.OPENROUTER_API_KEY).toBe("config-key"); + expect(process.env.OPENROUTER_API_KEY).toBeUndefined(); + }); + }); + it("blocks dangerous startup env vars from config env", async () => { await withEnvOverride( { diff --git a/src/config/env-vars.ts b/src/config/env-vars.ts index edfa44db30208..8692e163e2210 100644 --- a/src/config/env-vars.ts +++ b/src/config/env-vars.ts @@ -67,6 +67,15 @@ export function collectConfigEnvVars(cfg?: OpenClawConfig): Record Date: Sun, 8 Mar 2026 16:53:46 +0000 Subject: [PATCH 103/311] refactor: split models registry loading from persistence --- src/commands/models.list.e2e.test.ts | 23 ++++++++++++++--------- src/commands/models/list.list-command.ts | 4 ++++ src/commands/models/list.registry.ts | 8 ++------ 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 53a112d0451db..171893134a1e4 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -324,30 +324,35 @@ describe("models list/status", () => { await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); }); - it("loadModelRegistry persists using source config snapshot when provided", async () => { + it("loadModelRegistry does not persist models.json as a side effect", async () => { modelRegistryState.models = [OPENAI_MODEL]; modelRegistryState.available = [OPENAI_MODEL]; - const sourceConfig = { - models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret - }; const resolvedConfig = { models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret }; - await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never }); + await loadModelRegistry(resolvedConfig as never); - expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); - expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig); + expect(ensureOpenClawModelsJson).not.toHaveBeenCalled(); }); - it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => { + it("modelsListCommand persists using the write snapshot config when provided", async () => { modelRegistryState.models = [OPENAI_MODEL]; modelRegistryState.available = [OPENAI_MODEL]; + const sourceConfig = { + models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret + }; const resolvedConfig = { models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret }; + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: resolvedConfig, source: sourceConfig }, + writeOptions: {}, + }); + setDefaultModel("openai/gpt-4.1-mini"); + const runtime = makeRuntime(); - await loadModelRegistry(resolvedConfig as never); + await modelsListCommand({ all: true, json: true }, runtime); expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index afcd7b785d2a6..acb6c95761f0a 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -23,6 +23,7 @@ export async function modelsListCommand( ) { ensureFlagCompatibility(opts); const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js"); + const { ensureOpenClawModelsJson } = await import("../../agents/models-config.js"); const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({ commandName: "models list", runtime, @@ -42,6 +43,9 @@ export async function modelsListCommand( let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { + // Keep command behavior explicit: sync models.json from the source config + // before building the read-only model registry view. + await ensureOpenClawModelsJson(sourceConfig ?? cfg); const loaded = await loadModelRegistry(cfg, { sourceConfig }); modelRegistry = loaded.registry; models = loaded.models; diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 187b55176f540..340d49155df6a 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -8,7 +8,6 @@ import { resolveAwsSdkEnvVarName, resolveEnvApiKey, } from "../../agents/model-auth.js"; -import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -95,12 +94,9 @@ function loadAvailableModels(registry: ModelRegistry): Model[] { } export async function loadModelRegistry( - cfg: OpenClawConfig, - opts?: { sourceConfig?: OpenClawConfig }, + _cfg: OpenClawConfig, + _opts?: { sourceConfig?: OpenClawConfig }, ) { - // Persistence must be based on source config (pre-resolution) so SecretRef-managed - // credentials remain markers in models.json for command paths too. - await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); From ef2541ceb335b1ff69b43137b54683d7ccd4fe7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:54:56 +0000 Subject: [PATCH 104/311] refactor: centralize transcript provider quirks --- src/agents/provider-capabilities.test.ts | 40 ++++++++++-- src/agents/provider-capabilities.ts | 83 +++++++++++++++++++++++- src/agents/transcript-policy.ts | 61 ++++------------- 3 files changed, 131 insertions(+), 53 deletions(-) diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index d080c00f36d7d..5f97ac9574659 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + isAnthropicProviderFamily, + isOpenAiProviderFamily, requiresOpenAiCompatibleAnthropicToolPayload, resolveProviderCapabilities, resolveTranscriptToolCallIdMode, - sanitizesGeminiThoughtSignatures, + shouldDropThinkingBlocksForModel, + shouldSanitizeGeminiThoughtSignaturesForModel, supportsOpenAiCompatTurnValidation, } from "./provider-capabilities.js"; @@ -12,10 +15,14 @@ describe("resolveProviderCapabilities", () => { expect(resolveProviderCapabilities("anthropic")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", + providerFamily: "anthropic", preserveAnthropicThinkingSignatures: true, openAiCompatTurnValidation: true, geminiThoughtSignatureSanitization: false, transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: [], }); }); @@ -26,10 +33,14 @@ describe("resolveProviderCapabilities", () => { expect(resolveProviderCapabilities("kimi-code")).toEqual({ anthropicToolSchemaMode: "openai-functions", anthropicToolChoiceMode: "openai-string-modes", + providerFamily: "default", preserveAnthropicThinkingSignatures: false, openAiCompatTurnValidation: true, geminiThoughtSignatureSanitization: false, transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: [], }); }); @@ -40,9 +51,19 @@ describe("resolveProviderCapabilities", () => { }); it("resolves transcript thought-signature and tool-call quirks through the registry", () => { - expect(sanitizesGeminiThoughtSignatures("openrouter")).toBe(true); - expect(sanitizesGeminiThoughtSignatures("kilocode")).toBe(true); - expect(resolveTranscriptToolCallIdMode("mistral")).toBe("strict9"); + expect( + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider: "openrouter", + modelId: "google/gemini-2.5-pro-preview", + }), + ).toBe(true); + expect( + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider: "kilocode", + modelId: "gemini-2.0-flash", + }), + ).toBe(true); + expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9"); }); it("treats kimi aliases as anthropic tool payload compatibility providers", () => { @@ -50,4 +71,15 @@ describe("resolveProviderCapabilities", () => { expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true); expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); }); + + it("tracks provider families and model-specific transcript quirks in the registry", () => { + expect(isOpenAiProviderFamily("openai")).toBe(true); + expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "github-copilot", + modelId: "claude-3.7-sonnet", + }), + ).toBe(true); + }); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 57fd364fbee61..807c72e22f8bd 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -3,22 +3,36 @@ import { normalizeProviderId } from "./model-selection.js"; export type ProviderCapabilities = { anthropicToolSchemaMode: "native" | "openai-functions"; anthropicToolChoiceMode: "native" | "openai-string-modes"; + providerFamily: "default" | "openai" | "anthropic"; preserveAnthropicThinkingSignatures: boolean; openAiCompatTurnValidation: boolean; geminiThoughtSignatureSanitization: boolean; transcriptToolCallIdMode: "default" | "strict9"; + transcriptToolCallIdModelHints: string[]; + geminiThoughtSignatureModelHints: string[]; + dropThinkingBlockModelHints: string[]; }; const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", + providerFamily: "default", preserveAnthropicThinkingSignatures: true, openAiCompatTurnValidation: true, geminiThoughtSignatureSanitization: false, transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: [], }; const PROVIDER_CAPABILITIES: Record> = { + anthropic: { + providerFamily: "anthropic", + }, + "amazon-bedrock": { + providerFamily: "anthropic", + }, "kimi-coding": { anthropicToolSchemaMode: "openai-functions", anthropicToolChoiceMode: "openai-string-modes", @@ -26,17 +40,38 @@ const PROVIDER_CAPABILITIES: Record> = { }, mistral: { transcriptToolCallIdMode: "strict9", + transcriptToolCallIdModelHints: [ + "mistral", + "mixtral", + "codestral", + "pixtral", + "devstral", + "ministral", + "mistralai", + ], + }, + openai: { + providerFamily: "openai", + }, + "openai-codex": { + providerFamily: "openai", }, openrouter: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], }, opencode: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], }, kilocode: { geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, + "github-copilot": { + dropThinkingBlockModelHints: ["claude"], }, }; @@ -76,7 +111,51 @@ export function sanitizesGeminiThoughtSignatures(provider?: string | null): bool return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization; } -export function resolveTranscriptToolCallIdMode(provider?: string | null): "strict9" | undefined { - const mode = resolveProviderCapabilities(provider).transcriptToolCallIdMode; +function modelIncludesAnyHint(modelId: string | null | undefined, hints: string[]): boolean { + const normalized = (modelId ?? "").toLowerCase(); + return Boolean(normalized) && hints.some((hint) => normalized.includes(hint)); +} + +export function isOpenAiProviderFamily(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).providerFamily === "openai"; +} + +export function isAnthropicProviderFamily(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).providerFamily === "anthropic"; +} + +export function shouldDropThinkingBlocksForModel(params: { + provider?: string | null; + modelId?: string | null; +}): boolean { + return modelIncludesAnyHint( + params.modelId, + resolveProviderCapabilities(params.provider).dropThinkingBlockModelHints, + ); +} + +export function shouldSanitizeGeminiThoughtSignaturesForModel(params: { + provider?: string | null; + modelId?: string | null; +}): boolean { + const capabilities = resolveProviderCapabilities(params.provider); + return ( + capabilities.geminiThoughtSignatureSanitization && + modelIncludesAnyHint(params.modelId, capabilities.geminiThoughtSignatureModelHints) + ); +} + +export function resolveTranscriptToolCallIdMode( + provider?: string | null, + modelId?: string | null, +): "strict9" | undefined { + const capabilities = resolveProviderCapabilities(provider); + const mode = capabilities.transcriptToolCallIdMode; + if (mode === "strict9") { + return mode; + } + if (modelIncludesAnyHint(modelId, capabilities.transcriptToolCallIdModelHints)) { + return "strict9"; + } return mode === "strict9" ? mode : undefined; } diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 491c22ab59d52..d6d9ec5916a46 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,9 +1,12 @@ import { normalizeProviderId } from "./model-selection.js"; import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; import { + isAnthropicProviderFamily, + isOpenAiProviderFamily, preservesAnthropicThinkingSignatures, resolveTranscriptToolCallIdMode, - sanitizesGeminiThoughtSignatures, + shouldDropThinkingBlocksForModel, + shouldSanitizeGeminiThoughtSignaturesForModel, supportsOpenAiCompatTurnValidation, } from "./provider-capabilities.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; @@ -28,22 +31,12 @@ export type TranscriptPolicy = { allowSyntheticToolResults: boolean; }; -const MISTRAL_MODEL_HINTS = [ - "mistral", - "mixtral", - "codestral", - "pixtral", - "devstral", - "ministral", - "mistralai", -]; const OPENAI_MODEL_APIS = new Set([ "openai", "openai-completions", "openai-responses", "openai-codex-responses", ]); -const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -53,41 +46,15 @@ function isOpenAiApi(modelApi?: string | null): boolean { } function isOpenAiProvider(provider?: string | null): boolean { - if (!provider) { - return false; - } - return OPENAI_PROVIDERS.has(normalizeProviderId(provider)); + return isOpenAiProviderFamily(provider); } function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { 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" || normalized === "amazon-bedrock"; -} - -function isMistralModel(modelId?: string | null): boolean { - const normalizedModelId = (modelId ?? "").toLowerCase(); - if (!normalizedModelId) { - return false; - } - return MISTRAL_MODEL_HINTS.some((hint) => normalizedModelId.includes(hint)); -} - -function shouldSanitizeGeminiThoughtSignatures(params: { - provider?: string | null; - modelId?: string | null; -}): boolean { - if (!sanitizesGeminiThoughtSignatures(params.provider)) { - return false; - } - const modelId = (params.modelId ?? "").toLowerCase(); - if (!modelId) { - return false; - } - return modelId.includes("gemini"); + return isAnthropicProviderFamily(provider); } export function resolveTranscriptPolicy(params: { @@ -104,19 +71,19 @@ export function resolveTranscriptPolicy(params: { params.modelApi === "openai-completions" && !isOpenAi && supportsOpenAiCompatTurnValidation(provider); - const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider); - const isMistral = providerToolCallIdMode === "strict9" || isMistralModel(modelId); - const shouldSanitizeGeminiThoughtSignaturesForProvider = shouldSanitizeGeminiThoughtSignatures({ - provider, - modelId, - }); - const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude"); + const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider, modelId); + const isMistral = providerToolCallIdMode === "strict9"; + const shouldSanitizeGeminiThoughtSignaturesForProvider = + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider, + modelId, + }); const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with // non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text"). // Drop these blocks at send-time to keep sessions usable. - const dropThinkingBlocks = isCopilotClaude; + const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId }); const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || shouldSanitizeGeminiThoughtSignaturesForProvider; From f66bd105a4731cb79ad5b03765c76d90ddbd52f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:58:01 +0000 Subject: [PATCH 105/311] refactor: decompose implicit provider resolution --- ...s-config.providers.auth-provenance.test.ts | 6 +- src/agents/models-config.providers.ts | 382 ++++++++++-------- 2 files changed, 222 insertions(+), 166 deletions(-) diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts index 0a606762d6637..c06653973f3bc 100644 --- a/src/agents/models-config.providers.auth-provenance.test.ts +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -41,7 +41,7 @@ describe("models-config provider auth provenance", () => { "utf8", ); try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProviders({ agentDir, env: {} }); expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); @@ -78,7 +78,7 @@ describe("models-config provider auth provenance", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProviders({ agentDir, env: {} }); expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); @@ -114,7 +114,7 @@ describe("models-config provider auth provenance", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProviders({ agentDir, env: {} }); expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 8aca2c26d0e95..9f6316ecc0216 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1126,102 +1126,153 @@ async function buildKilocodeProviderWithDiscovery(): Promise { }; } -export async function resolveImplicitProviders(params: { +type ImplicitProviderParams = { agentDir: string; config?: OpenClawConfig; env?: NodeJS.ProcessEnv; explicitProviders?: Record | null; -}): Promise { - const providers: Record = {}; - const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const resolveProviderApiKey = ( - provider: string, - ): { apiKey: string | undefined; discoveryApiKey?: string } => { - const envVar = resolveEnvApiKeyVarName(provider, env); - if (envVar) { - return { - apiKey: envVar, - discoveryApiKey: toDiscoveryApiKey(env[envVar]), - }; +}; + +type ProviderApiKeyResolver = (provider: string) => { + apiKey: string | undefined; + discoveryApiKey?: string; +}; + +type ImplicitProviderContext = ImplicitProviderParams & { + authStore: ReturnType; + env: NodeJS.ProcessEnv; + resolveProviderApiKey: ProviderApiKeyResolver; +}; + +type ImplicitProviderLoader = ( + ctx: ImplicitProviderContext, +) => Promise | undefined>; + +function withApiKey( + providerKey: string, + build: (params: { + apiKey: string; + discoveryApiKey?: string; + }) => ProviderConfig | Promise, +): ImplicitProviderLoader { + return async (ctx) => { + const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(providerKey); + if (!apiKey) { + return undefined; } - const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore, env }); return { - apiKey: fromProfiles?.apiKey, - discoveryApiKey: fromProfiles?.discoveryApiKey, + [providerKey]: await build({ apiKey, discoveryApiKey }), }; }; +} - const minimaxKey = resolveProviderApiKey("minimax").apiKey; - if (minimaxKey) { - providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; - } - - const minimaxPortalEnvKey = resolveEnvApiKeyVarName("minimax-portal", env); - const minimaxOauthProfile = listProfilesForProvider(authStore, "minimax-portal"); - if (minimaxPortalEnvKey || minimaxOauthProfile.length > 0) { - providers["minimax-portal"] = { - ...buildMinimaxPortalProvider(), - apiKey: MINIMAX_OAUTH_MARKER, +function withProfilePresence( + providerKey: string, + build: () => ProviderConfig | Promise, +): ImplicitProviderLoader { + return async (ctx) => { + if (listProfilesForProvider(ctx.authStore, providerKey).length === 0) { + return undefined; + } + return { + [providerKey]: await build(), }; - } - - const moonshotKey = resolveProviderApiKey("moonshot").apiKey; - if (moonshotKey) { - providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; - } - - const kimiCodingKey = resolveProviderApiKey("kimi-coding").apiKey; - if (kimiCodingKey) { - providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey }; - } + }; +} - const syntheticKey = resolveProviderApiKey("synthetic").apiKey; - if (syntheticKey) { - providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey }; +function mergeImplicitProviderSet( + target: Record, + additions: Record | undefined, +): void { + if (!additions) { + return; } - - const veniceKey = resolveProviderApiKey("venice").apiKey; - if (veniceKey) { - providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; + for (const [key, value] of Object.entries(additions)) { + target[key] = value; } +} - const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal"); - if (qwenProfiles.length > 0) { - providers["qwen-portal"] = { - ...buildQwenPortalProvider(), - apiKey: QWEN_OAUTH_MARKER, +const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ + withApiKey("minimax", async ({ apiKey }) => ({ ...buildMinimaxProvider(), apiKey })), + withApiKey("moonshot", async ({ apiKey }) => ({ ...buildMoonshotProvider(), apiKey })), + withApiKey("kimi-coding", async ({ apiKey }) => ({ ...buildKimiCodingProvider(), apiKey })), + withApiKey("synthetic", async ({ apiKey }) => ({ ...buildSyntheticProvider(), apiKey })), + withApiKey("venice", async ({ apiKey }) => ({ ...(await buildVeniceProvider()), apiKey })), + withApiKey("xiaomi", async ({ apiKey }) => ({ ...buildXiaomiProvider(), apiKey })), + withApiKey("vercel-ai-gateway", async ({ apiKey }) => ({ + ...(await buildVercelAiGatewayProvider()), + apiKey, + })), + withApiKey("together", async ({ apiKey }) => ({ ...buildTogetherProvider(), apiKey })), + withApiKey("huggingface", async ({ apiKey, discoveryApiKey }) => ({ + ...(await buildHuggingfaceProvider(discoveryApiKey)), + apiKey, + })), + withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })), + withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })), + withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), + withApiKey("kilocode", async ({ apiKey }) => ({ + ...(await buildKilocodeProviderWithDiscovery()), + apiKey, + })), +]; + +const PROFILE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ + async (ctx) => { + const envKey = resolveEnvApiKeyVarName("minimax-portal", ctx.env); + const hasProfiles = listProfilesForProvider(ctx.authStore, "minimax-portal").length > 0; + if (!envKey && !hasProfiles) { + return undefined; + } + return { + "minimax-portal": { + ...buildMinimaxPortalProvider(), + apiKey: MINIMAX_OAUTH_MARKER, + }, }; - } - - const volcengineKey = resolveProviderApiKey("volcengine").apiKey; - if (volcengineKey) { - providers.volcengine = { ...buildDoubaoProvider(), apiKey: volcengineKey }; - providers["volcengine-plan"] = { - ...buildDoubaoCodingProvider(), - apiKey: volcengineKey, + }, + withProfilePresence("qwen-portal", async () => ({ + ...buildQwenPortalProvider(), + apiKey: QWEN_OAUTH_MARKER, + })), + withProfilePresence("openai-codex", async () => buildOpenAICodexProvider()), +]; + +const PAIRED_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ + async (ctx) => { + const volcengineKey = ctx.resolveProviderApiKey("volcengine").apiKey; + if (!volcengineKey) { + return undefined; + } + return { + volcengine: { ...buildDoubaoProvider(), apiKey: volcengineKey }, + "volcengine-plan": { + ...buildDoubaoCodingProvider(), + apiKey: volcengineKey, + }, }; - } - - const byteplusKey = resolveProviderApiKey("byteplus").apiKey; - if (byteplusKey) { - providers.byteplus = { ...buildBytePlusProvider(), apiKey: byteplusKey }; - providers["byteplus-plan"] = { - ...buildBytePlusCodingProvider(), - apiKey: byteplusKey, + }, + async (ctx) => { + const byteplusKey = ctx.resolveProviderApiKey("byteplus").apiKey; + if (!byteplusKey) { + return undefined; + } + return { + byteplus: { ...buildBytePlusProvider(), apiKey: byteplusKey }, + "byteplus-plan": { + ...buildBytePlusCodingProvider(), + apiKey: byteplusKey, + }, }; - } + }, +]; - const xiaomiKey = resolveProviderApiKey("xiaomi").apiKey; - if (xiaomiKey) { - providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey }; - } - - const cloudflareProfiles = listProfilesForProvider(authStore, "cloudflare-ai-gateway"); +async function resolveCloudflareAiGatewayImplicitProvider( + ctx: ImplicitProviderContext, +): Promise | undefined> { + const cloudflareProfiles = listProfilesForProvider(ctx.authStore, "cloudflare-ai-gateway"); for (const profileId of cloudflareProfiles) { - const cred = authStore.profiles[profileId]; + const cred = ctx.authStore.profiles[profileId]; if (cred?.type !== "api_key") { continue; } @@ -1234,114 +1285,119 @@ export async function resolveImplicitProviders(params: { if (!baseUrl) { continue; } - const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway", env); - const profileApiKey = resolveApiKeyFromCredential(cred, env)?.apiKey; + const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway", ctx.env); + const profileApiKey = resolveApiKeyFromCredential(cred, ctx.env)?.apiKey; const apiKey = envVarApiKey ?? profileApiKey ?? ""; if (!apiKey) { continue; } - providers["cloudflare-ai-gateway"] = { - baseUrl, - api: "anthropic-messages", - apiKey, - models: [buildCloudflareAiGatewayModelDefinition()], - }; - break; - } - - const vercelAiGatewayKey = resolveProviderApiKey("vercel-ai-gateway").apiKey; - if (vercelAiGatewayKey) { - providers["vercel-ai-gateway"] = { - ...(await buildVercelAiGatewayProvider()), - apiKey: vercelAiGatewayKey, + return { + "cloudflare-ai-gateway": { + baseUrl, + api: "anthropic-messages", + apiKey, + models: [buildCloudflareAiGatewayModelDefinition()], + }, }; } + return undefined; +} - // Ollama provider - auto-discover if running locally, or add if explicitly configured. - // Use the user's configured baseUrl (from explicit providers) for model - // discovery so that remote / non-default Ollama instances are reachable. - // Skip discovery when explicit models are already defined. - const ollamaKey = resolveProviderApiKey("ollama").apiKey; - const explicitOllama = params.explicitProviders?.ollama; +async function resolveOllamaImplicitProvider( + ctx: ImplicitProviderContext, +): Promise | undefined> { + const ollamaKey = ctx.resolveProviderApiKey("ollama").apiKey; + const explicitOllama = ctx.explicitProviders?.ollama; const hasExplicitModels = Array.isArray(explicitOllama?.models) && explicitOllama.models.length > 0; if (hasExplicitModels && explicitOllama) { - providers.ollama = { - ...explicitOllama, - baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl), - api: explicitOllama.api ?? "ollama", - apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, + return { + ollama: { + ...explicitOllama, + baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl), + api: explicitOllama.api ?? "ollama", + apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, + }, }; - } else { - const ollamaBaseUrl = explicitOllama?.baseUrl; - const hasExplicitOllamaConfig = Boolean(explicitOllama); - // Only suppress warnings for implicit local probing when user has not - // explicitly configured Ollama. - const ollamaProvider = await buildOllamaProvider(ollamaBaseUrl, { - quiet: !ollamaKey && !hasExplicitOllamaConfig, - }); - if (ollamaProvider.models.length > 0 || ollamaKey || explicitOllama?.apiKey) { - providers.ollama = { - ...ollamaProvider, - apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, - }; - } } - // vLLM provider - OpenAI-compatible local server (opt-in via env/profile). - // If explicitly configured, keep user-defined models/settings as-is. - if (!params.explicitProviders?.vllm) { - const { apiKey: vllmKey, discoveryApiKey } = resolveProviderApiKey("vllm"); - if (vllmKey) { - providers.vllm = { - ...(await buildVllmProvider({ apiKey: discoveryApiKey })), - apiKey: vllmKey, - }; - } - } - - const togetherKey = resolveProviderApiKey("together").apiKey; - if (togetherKey) { - providers.together = { - ...buildTogetherProvider(), - apiKey: togetherKey, - }; + const ollamaBaseUrl = explicitOllama?.baseUrl; + const hasExplicitOllamaConfig = Boolean(explicitOllama); + const ollamaProvider = await buildOllamaProvider(ollamaBaseUrl, { + quiet: !ollamaKey && !hasExplicitOllamaConfig, + }); + if (ollamaProvider.models.length === 0 && !ollamaKey && !explicitOllama?.apiKey) { + return undefined; } + return { + ollama: { + ...ollamaProvider, + apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, + }, + }; +} - const { apiKey: huggingfaceKey, discoveryApiKey: huggingfaceDiscoveryApiKey } = - resolveProviderApiKey("huggingface"); - if (huggingfaceKey) { - const hfProvider = await buildHuggingfaceProvider(huggingfaceDiscoveryApiKey); - providers.huggingface = { - ...hfProvider, - apiKey: huggingfaceKey, - }; +async function resolveVllmImplicitProvider( + ctx: ImplicitProviderContext, +): Promise | undefined> { + if (ctx.explicitProviders?.vllm) { + return undefined; } - - const qianfanKey = resolveProviderApiKey("qianfan").apiKey; - if (qianfanKey) { - providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey }; + const { apiKey: vllmKey, discoveryApiKey } = ctx.resolveProviderApiKey("vllm"); + if (!vllmKey) { + return undefined; } + return { + vllm: { + ...(await buildVllmProvider({ apiKey: discoveryApiKey })), + apiKey: vllmKey, + }, + }; +} - const openrouterKey = resolveProviderApiKey("openrouter").apiKey; - if (openrouterKey) { - providers.openrouter = { ...buildOpenrouterProvider(), apiKey: openrouterKey }; - } +export async function resolveImplicitProviders( + params: ImplicitProviderParams, +): Promise { + const providers: Record = {}; + const env = params.env ?? process.env; + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const resolveProviderApiKey: ProviderApiKeyResolver = ( + provider: string, + ): { apiKey: string | undefined; discoveryApiKey?: string } => { + const envVar = resolveEnvApiKeyVarName(provider, env); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(env[envVar]), + }; + } + const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore, env }); + return { + apiKey: fromProfiles?.apiKey, + discoveryApiKey: fromProfiles?.discoveryApiKey, + }; + }; + const context: ImplicitProviderContext = { + ...params, + authStore, + env, + resolveProviderApiKey, + }; - const openaiCodexProfiles = listProfilesForProvider(authStore, "openai-codex"); - if (openaiCodexProfiles.length > 0) { - providers["openai-codex"] = buildOpenAICodexProvider(); + for (const loader of SIMPLE_IMPLICIT_PROVIDER_LOADERS) { + mergeImplicitProviderSet(providers, await loader(context)); } - - const nvidiaKey = resolveProviderApiKey("nvidia").apiKey; - if (nvidiaKey) { - providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey }; + for (const loader of PROFILE_IMPLICIT_PROVIDER_LOADERS) { + mergeImplicitProviderSet(providers, await loader(context)); } - - const kilocodeKey = resolveProviderApiKey("kilocode").apiKey; - if (kilocodeKey) { - providers.kilocode = { ...(await buildKilocodeProviderWithDiscovery()), apiKey: kilocodeKey }; + for (const loader of PAIRED_IMPLICIT_PROVIDER_LOADERS) { + mergeImplicitProviderSet(providers, await loader(context)); } + mergeImplicitProviderSet(providers, await resolveCloudflareAiGatewayImplicitProvider(context)); + mergeImplicitProviderSet(providers, await resolveOllamaImplicitProvider(context)); + mergeImplicitProviderSet(providers, await resolveVllmImplicitProvider(context)); if (!providers["github-copilot"]) { const implicitCopilot = await resolveImplicitCopilotProvider({ From e53d840fed7a2f6a1cf4732ad68c318f09d9ceea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:59:48 +0000 Subject: [PATCH 106/311] refactor: extract openai stream wrappers --- src/agents/pi-embedded-runner/extra-params.ts | 270 +----------------- .../openai-stream-wrappers.ts | 257 +++++++++++++++++ 2 files changed, 264 insertions(+), 263 deletions(-) create mode 100644 src/agents/pi-embedded-runner/openai-stream-wrappers.ts diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 23178e1d1fa9d..89451dc614dbf 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -9,6 +9,13 @@ import { usesOpenAiStringModeAnthropicToolChoice, } from "../provider-capabilities.js"; import { log } from "./logger.js"; +import { + createCodexDefaultTransportWrapper, + createOpenAIDefaultTransportWrapper, + createOpenAIResponsesContextManagementWrapper, + createOpenAIServiceTierWrapper, + resolveOpenAIServiceTier, +} from "./openai-stream-wrappers.js"; const OPENROUTER_APP_HEADERS: Record = { "HTTP-Referer": "https://openclaw.ai", @@ -25,11 +32,6 @@ function resolveKilocodeAppHeaders(): Record { const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07"; const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const; -// NOTE: We only force `store=true` for *direct* OpenAI Responses. -// Codex responses (chatgpt.com/backend-api/codex/responses) require `store=false`. -const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]); -const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai-responses"]); - /** * Resolve provider-specific extra params from model config. * Used to pass through stream params like temperature/maxTokens. @@ -69,7 +71,6 @@ export function resolveExtraParams(params: { } type CacheRetention = "none" | "short" | "long"; -type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; type CacheRetentionStreamOptions = Partial & { cacheRetention?: CacheRetention; openaiWsWarmup?: boolean; @@ -214,263 +215,6 @@ function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): Stream }); } -function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean { - if (typeof baseUrl !== "string" || !baseUrl.trim()) { - return false; - } - - try { - const host = new URL(baseUrl).hostname.toLowerCase(); - return ( - host === "api.openai.com" || host === "chatgpt.com" || host.endsWith(".openai.azure.com") - ); - } catch { - const normalized = baseUrl.toLowerCase(); - return ( - normalized.includes("api.openai.com") || - normalized.includes("chatgpt.com") || - normalized.includes(".openai.azure.com") - ); - } -} - -function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean { - if (typeof baseUrl !== "string" || !baseUrl.trim()) { - return false; - } - - try { - return new URL(baseUrl).hostname.toLowerCase() === "api.openai.com"; - } catch { - return baseUrl.toLowerCase().includes("api.openai.com"); - } -} - -function shouldForceResponsesStore(model: { - api?: unknown; - provider?: unknown; - baseUrl?: unknown; - compat?: { supportsStore?: boolean }; -}): boolean { - // Never force store=true when the model explicitly declares supportsStore=false - // (e.g. Azure OpenAI Responses API without server-side persistence). - if (model.compat?.supportsStore === false) { - return false; - } - if (typeof model.api !== "string" || typeof model.provider !== "string") { - return false; - } - if (!OPENAI_RESPONSES_APIS.has(model.api)) { - return false; - } - if (!OPENAI_RESPONSES_PROVIDERS.has(model.provider)) { - return false; - } - return isDirectOpenAIBaseUrl(model.baseUrl); -} - -function parsePositiveInteger(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return Math.floor(value); - } - if (typeof value === "string") { - const parsed = Number.parseInt(value, 10); - if (Number.isFinite(parsed) && parsed > 0) { - return parsed; - } - } - return undefined; -} - -function resolveOpenAIResponsesCompactThreshold(model: { contextWindow?: unknown }): number { - const contextWindow = parsePositiveInteger(model.contextWindow); - if (contextWindow) { - return Math.max(1_000, Math.floor(contextWindow * 0.7)); - } - return 80_000; -} - -function shouldEnableOpenAIResponsesServerCompaction( - model: { - api?: unknown; - provider?: unknown; - baseUrl?: unknown; - compat?: { supportsStore?: boolean }; - }, - extraParams: Record | undefined, -): boolean { - const configured = extraParams?.responsesServerCompaction; - if (configured === false) { - return false; - } - if (!shouldForceResponsesStore(model)) { - return false; - } - if (configured === true) { - return true; - } - // Auto-enable for direct OpenAI Responses models. - return model.provider === "openai"; -} - -function shouldStripResponsesStore( - model: { api?: unknown; compat?: { supportsStore?: boolean } }, - forceStore: boolean, -): boolean { - if (forceStore) { - return false; - } - if (typeof model.api !== "string") { - return false; - } - return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false; -} - -function applyOpenAIResponsesPayloadOverrides(params: { - payloadObj: Record; - forceStore: boolean; - stripStore: boolean; - useServerCompaction: boolean; - compactThreshold: number; -}): void { - if (params.forceStore) { - params.payloadObj.store = true; - } - if (params.stripStore) { - delete params.payloadObj.store; - } - if (params.useServerCompaction && params.payloadObj.context_management === undefined) { - params.payloadObj.context_management = [ - { - type: "compaction", - compact_threshold: params.compactThreshold, - }, - ]; - } -} - -function createOpenAIResponsesContextManagementWrapper( - baseStreamFn: StreamFn | undefined, - extraParams: Record | undefined, -): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - const forceStore = shouldForceResponsesStore(model); - const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams); - // Strip `store` from the payload when the model declares supportsStore=false. - // pi-ai upstream hardcodes `store: false` for Responses API; strict - // OpenAI-compatible endpoints (e.g. Gemini via Cloudflare) reject it. - const stripStore = shouldStripResponsesStore(model, forceStore); - if (!forceStore && !useServerCompaction && !stripStore) { - return underlying(model, context, options); - } - - const compactThreshold = - parsePositiveInteger(extraParams?.responsesCompactThreshold) ?? - resolveOpenAIResponsesCompactThreshold(model); - const originalOnPayload = options?.onPayload; - return underlying(model, context, { - ...options, - onPayload: (payload) => { - if (payload && typeof payload === "object") { - applyOpenAIResponsesPayloadOverrides({ - payloadObj: payload as Record, - forceStore, - stripStore, - useServerCompaction, - compactThreshold, - }); - } - originalOnPayload?.(payload); - }, - }); - }; -} - -function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if ( - normalized === "auto" || - normalized === "default" || - normalized === "flex" || - normalized === "priority" - ) { - return normalized; - } - return undefined; -} - -function resolveOpenAIServiceTier( - extraParams: Record | undefined, -): OpenAIServiceTier | undefined { - const raw = extraParams?.serviceTier ?? extraParams?.service_tier; - const normalized = normalizeOpenAIServiceTier(raw); - if (raw !== undefined && normalized === undefined) { - const rawSummary = typeof raw === "string" ? raw : typeof raw; - log.warn(`ignoring invalid OpenAI service tier param: ${rawSummary}`); - } - return normalized; -} - -function createOpenAIServiceTierWrapper( - baseStreamFn: StreamFn | undefined, - serviceTier: OpenAIServiceTier, -): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - if ( - model.api !== "openai-responses" || - model.provider !== "openai" || - !isOpenAIPublicApiBaseUrl(model.baseUrl) - ) { - return underlying(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.service_tier === undefined) { - payloadObj.service_tier = serviceTier; - } - } - originalOnPayload?.(payload); - }, - }); - }; -} - -function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => - underlying(model, context, { - ...options, - transport: options?.transport ?? "auto", - }); -} - -function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - const typedOptions = options as - | (SimpleStreamOptions & { openaiWsWarmup?: boolean }) - | undefined; - const mergedOptions = { - ...options, - transport: options?.transport ?? "auto", - // Warm-up is optional in OpenAI docs; enabled by default here for lower - // first-turn latency on WebSocket sessions. Set params.openaiWsWarmup=false - // to disable per model. - openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true, - } as SimpleStreamOptions; - return underlying(model, context, mergedOptions); - }; -} - function isAnthropic1MModel(modelId: string): boolean { const normalized = modelId.trim().toLowerCase(); return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts new file mode 100644 index 0000000000000..fc72d9ca0fe54 --- /dev/null +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -0,0 +1,257 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { SimpleStreamOptions } from "@mariozechner/pi-ai"; +import { streamSimple } from "@mariozechner/pi-ai"; +import { log } from "./logger.js"; + +type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; + +const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]); +const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai-responses"]); + +function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return false; + } + + try { + const host = new URL(baseUrl).hostname.toLowerCase(); + return ( + host === "api.openai.com" || host === "chatgpt.com" || host.endsWith(".openai.azure.com") + ); + } catch { + const normalized = baseUrl.toLowerCase(); + return ( + normalized.includes("api.openai.com") || + normalized.includes("chatgpt.com") || + normalized.includes(".openai.azure.com") + ); + } +} + +function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return false; + } + + try { + return new URL(baseUrl).hostname.toLowerCase() === "api.openai.com"; + } catch { + return baseUrl.toLowerCase().includes("api.openai.com"); + } +} + +function shouldForceResponsesStore(model: { + api?: unknown; + provider?: unknown; + baseUrl?: unknown; + compat?: { supportsStore?: boolean }; +}): boolean { + if (model.compat?.supportsStore === false) { + return false; + } + if (typeof model.api !== "string" || typeof model.provider !== "string") { + return false; + } + if (!OPENAI_RESPONSES_APIS.has(model.api)) { + return false; + } + if (!OPENAI_RESPONSES_PROVIDERS.has(model.provider)) { + return false; + } + return isDirectOpenAIBaseUrl(model.baseUrl); +} + +function parsePositiveInteger(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; +} + +function resolveOpenAIResponsesCompactThreshold(model: { contextWindow?: unknown }): number { + const contextWindow = parsePositiveInteger(model.contextWindow); + if (contextWindow) { + return Math.max(1_000, Math.floor(contextWindow * 0.7)); + } + return 80_000; +} + +function shouldEnableOpenAIResponsesServerCompaction( + model: { + api?: unknown; + provider?: unknown; + baseUrl?: unknown; + compat?: { supportsStore?: boolean }; + }, + extraParams: Record | undefined, +): boolean { + const configured = extraParams?.responsesServerCompaction; + if (configured === false) { + return false; + } + if (!shouldForceResponsesStore(model)) { + return false; + } + if (configured === true) { + return true; + } + return model.provider === "openai"; +} + +function shouldStripResponsesStore( + model: { api?: unknown; compat?: { supportsStore?: boolean } }, + forceStore: boolean, +): boolean { + if (forceStore) { + return false; + } + if (typeof model.api !== "string") { + return false; + } + return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false; +} + +function applyOpenAIResponsesPayloadOverrides(params: { + payloadObj: Record; + forceStore: boolean; + stripStore: boolean; + useServerCompaction: boolean; + compactThreshold: number; +}): void { + if (params.forceStore) { + params.payloadObj.store = true; + } + if (params.stripStore) { + delete params.payloadObj.store; + } + if (params.useServerCompaction && params.payloadObj.context_management === undefined) { + params.payloadObj.context_management = [ + { + type: "compaction", + compact_threshold: params.compactThreshold, + }, + ]; + } +} + +function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if ( + normalized === "auto" || + normalized === "default" || + normalized === "flex" || + normalized === "priority" + ) { + return normalized; + } + return undefined; +} + +export function resolveOpenAIServiceTier( + extraParams: Record | undefined, +): OpenAIServiceTier | undefined { + const raw = extraParams?.serviceTier ?? extraParams?.service_tier; + const normalized = normalizeOpenAIServiceTier(raw); + if (raw !== undefined && normalized === undefined) { + const rawSummary = typeof raw === "string" ? raw : typeof raw; + log.warn(`ignoring invalid OpenAI service tier param: ${rawSummary}`); + } + return normalized; +} + +export function createOpenAIResponsesContextManagementWrapper( + baseStreamFn: StreamFn | undefined, + extraParams: Record | undefined, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const forceStore = shouldForceResponsesStore(model); + const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams); + const stripStore = shouldStripResponsesStore(model, forceStore); + if (!forceStore && !useServerCompaction && !stripStore) { + return underlying(model, context, options); + } + + const compactThreshold = + parsePositiveInteger(extraParams?.responsesCompactThreshold) ?? + resolveOpenAIResponsesCompactThreshold(model); + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + applyOpenAIResponsesPayloadOverrides({ + payloadObj: payload as Record, + forceStore, + stripStore, + useServerCompaction, + compactThreshold, + }); + } + originalOnPayload?.(payload); + }, + }); + }; +} + +export function createOpenAIServiceTierWrapper( + baseStreamFn: StreamFn | undefined, + serviceTier: OpenAIServiceTier, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + if ( + model.api !== "openai-responses" || + model.provider !== "openai" || + !isOpenAIPublicApiBaseUrl(model.baseUrl) + ) { + return underlying(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.service_tier === undefined) { + payloadObj.service_tier = serviceTier; + } + } + originalOnPayload?.(payload); + }, + }); + }; +} + +export function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => + underlying(model, context, { + ...options, + transport: options?.transport ?? "auto", + }); +} + +export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const typedOptions = options as + | (SimpleStreamOptions & { openaiWsWarmup?: boolean }) + | undefined; + const mergedOptions = { + ...options, + transport: options?.transport ?? "auto", + openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true, + } as SimpleStreamOptions; + return underlying(model, context, mergedOptions); + }; +} From f493b03202114696cdc82b2122ffe06c3c70968b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:00:47 +0000 Subject: [PATCH 107/311] refactor: validate bundled extension release metadata --- scripts/release-check.ts | 77 ++++++++++++++++++++++++++++++++++---- test/release-check.test.ts | 40 ++++++++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/scripts/release-check.ts b/scripts/release-check.ts index db396672ff77f..df79b69f25c2b 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -22,6 +22,11 @@ type PackageJson = { }; }; }; +type BundledExtension = { id: string; packageJson: PackageJson }; +type BundledExtensionMetadata = BundledExtension & { + npmSpec?: string; + rootDependencyMirrorAllowlist: string[]; +}; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -133,7 +138,7 @@ function normalizePluginSyncVersion(version: string): string { export function collectBundledExtensionRootDependencyGapErrors(params: { rootPackage: PackageJson; - extensions: Array<{ id: string; packageJson: PackageJson }>; + extensions: BundledExtension[]; }): string[] { const rootDeps = { ...params.rootPackage.dependencies, @@ -141,17 +146,15 @@ export function collectBundledExtensionRootDependencyGapErrors(params: { }; const errors: string[] = []; - for (const extension of params.extensions) { - if (!extension.packageJson.openclaw?.install?.npmSpec) { + for (const extension of normalizeBundledExtensionMetadata(params.extensions)) { + if (!extension.npmSpec) { continue; } const missing = Object.keys(extension.packageJson.dependencies ?? {}) .filter((dep) => dep !== "openclaw" && !rootDeps[dep]) .toSorted(); - const allowlisted = [ - ...(extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []), - ].toSorted(); + const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted(); if (missing.join("\n") !== allowlisted.join("\n")) { const unexpected = missing.filter((dep) => !allowlisted.includes(dep)); const resolved = allowlisted.filter((dep) => !missing.includes(dep)); @@ -172,7 +175,56 @@ export function collectBundledExtensionRootDependencyGapErrors(params: { return errors; } -function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJson }> { +function normalizeBundledExtensionMetadata( + extensions: BundledExtension[], +): BundledExtensionMetadata[] { + return extensions.map((extension) => ({ + ...extension, + npmSpec: + typeof extension.packageJson.openclaw?.install?.npmSpec === "string" + ? extension.packageJson.openclaw.install.npmSpec.trim() + : undefined, + rootDependencyMirrorAllowlist: + extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ) ?? [], + })); +} + +export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] { + const errors: string[] = []; + for (const extension of extensions) { + const install = extension.packageJson.openclaw?.install; + if ( + install && + (!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim()) + ) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`, + ); + } + + const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; + if (allowlist === undefined) { + continue; + } + if (!Array.isArray(allowlist)) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`, + ); + continue; + } + const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim()); + if (invalidEntries.length > 0) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`, + ); + } + } + return errors; +} + +function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory(), @@ -195,9 +247,18 @@ function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJso function checkBundledExtensionRootDependencyMirrors() { const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson; + const extensions = collectBundledExtensions(); + const manifestErrors = collectBundledExtensionManifestErrors(extensions); + if (manifestErrors.length > 0) { + console.error("release-check: bundled extension manifest validation failed:"); + for (const error of manifestErrors) { + console.error(` - ${error}`); + } + process.exit(1); + } const errors = collectBundledExtensionRootDependencyGapErrors({ rootPackage, - extensions: collectBundledExtensions(), + extensions, }); if (errors.length > 0) { console.error("release-check: bundled extension root dependency mirror validation failed:"); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 8a9ec8fea0ef5..636cc9bb39a55 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { collectAppcastSparkleVersionErrors, + collectBundledExtensionManifestErrors, collectBundledExtensionRootDependencyGapErrors, } from "../scripts/release-check.ts"; @@ -110,3 +111,42 @@ describe("collectBundledExtensionRootDependencyGapErrors", () => { ]); }); }); + +describe("collectBundledExtensionManifestErrors", () => { + it("flags invalid bundled extension install metadata", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "broken", + packageJson: { + openclaw: { + install: { npmSpec: " " }, + }, + }, + }, + ]), + ).toEqual([ + "bundled extension 'broken' manifest invalid | openclaw.install.npmSpec must be a non-empty string", + ]); + }); + + it("flags invalid release-check allowlist metadata", () => { + expect( + collectBundledExtensionManifestErrors([ + { + id: "broken", + packageJson: { + openclaw: { + install: { npmSpec: "@openclaw/broken" }, + releaseChecks: { + rootDependencyMirrorAllowlist: ["ok", ""], + }, + }, + }, + }, + ]), + ).toEqual([ + "bundled extension 'broken' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings", + ]); + }); +}); From 6094035054bebbc42ce4c92ef221aac742c55a63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:09:16 +0000 Subject: [PATCH 108/311] refactor: extract static provider builders --- src/agents/models-config.providers.static.ts | 440 +++++++++++++++++ src/agents/models-config.providers.ts | 479 ++----------------- 2 files changed, 473 insertions(+), 446 deletions(-) create mode 100644 src/agents/models-config.providers.static.ts diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts new file mode 100644 index 0000000000000..638943cc4a4ce --- /dev/null +++ b/src/agents/models-config.providers.static.ts @@ -0,0 +1,440 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_CONTEXT_WINDOW, + KILOCODE_DEFAULT_COST, + KILOCODE_DEFAULT_MAX_TOKENS, + KILOCODE_MODEL_CATALOG, +} from "../providers/kilocode-shared.js"; +import { + buildBytePlusModelDefinition, + BYTEPLUS_BASE_URL, + BYTEPLUS_MODEL_CATALOG, + BYTEPLUS_CODING_BASE_URL, + BYTEPLUS_CODING_MODEL_CATALOG, +} from "./byteplus-models.js"; +import { + buildDoubaoModelDefinition, + DOUBAO_BASE_URL, + DOUBAO_MODEL_CATALOG, + DOUBAO_CODING_BASE_URL, + DOUBAO_CODING_MODEL_CATALOG, +} from "./doubao-models.js"; +import { + buildSyntheticModelDefinition, + SYNTHETIC_BASE_URL, + SYNTHETIC_MODEL_CATALOG, +} from "./synthetic-models.js"; +import { + TOGETHER_BASE_URL, + TOGETHER_MODEL_CATALOG, + buildTogetherModelDefinition, +} from "./together-models.js"; + +type ModelsConfig = NonNullable; +type ProviderConfig = NonNullable[string]; +type ProviderModelConfig = NonNullable[number]; + +const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; +const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; +const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; +const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; +const MINIMAX_DEFAULT_MAX_TOKENS = 8192; +const MINIMAX_API_COST = { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.12, +}; + +function buildMinimaxModel(params: { + id: string; + name: string; + reasoning: boolean; + input: ProviderModelConfig["input"]; +}): ProviderModelConfig { + return { + id: params.id, + name: params.name, + reasoning: params.reasoning, + input: params.input, + cost: MINIMAX_API_COST, + contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, + maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, + }; +} + +function buildMinimaxTextModel(params: { + id: string; + name: string; + reasoning: boolean; +}): ProviderModelConfig { + return buildMinimaxModel({ ...params, input: ["text"] }); +} + +const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; +export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; +const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; +const XIAOMI_DEFAULT_MAX_TOKENS = 8192; +const XIAOMI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; +const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; +const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; +const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; +const MOONSHOT_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; +const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5"; +const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; +const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; +const KIMI_CODING_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; +const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; +const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; +const QWEN_PORTAL_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_DEFAULT_MODEL_ID = "auto"; +const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000; +const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; +const OPENROUTER_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2"; +export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2"; +const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304; +const QIANFAN_DEFAULT_MAX_TOKENS = 32768; +const QIANFAN_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; +const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; +const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072; +const NVIDIA_DEFAULT_MAX_TOKENS = 4096; +const NVIDIA_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + +export function buildMinimaxProvider(): ProviderConfig { + return { + baseUrl: MINIMAX_PORTAL_BASE_URL, + api: "anthropic-messages", + authHeader: true, + models: [ + buildMinimaxModel({ + id: MINIMAX_DEFAULT_VISION_MODEL_ID, + name: "MiniMax VL 01", + reasoning: false, + input: ["text", "image"], + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.5", + name: "MiniMax M2.5", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.5-highspeed", + name: "MiniMax M2.5 Highspeed", + reasoning: true, + }), + ], + }; +} + +export function buildMinimaxPortalProvider(): ProviderConfig { + return { + baseUrl: MINIMAX_PORTAL_BASE_URL, + api: "anthropic-messages", + authHeader: true, + models: [ + buildMinimaxModel({ + id: MINIMAX_DEFAULT_VISION_MODEL_ID, + name: "MiniMax VL 01", + reasoning: false, + input: ["text", "image"], + }), + buildMinimaxTextModel({ + id: MINIMAX_DEFAULT_MODEL_ID, + name: "MiniMax M2.5", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.5-highspeed", + name: "MiniMax M2.5 Highspeed", + reasoning: true, + }), + ], + }; +} + +export function buildMoonshotProvider(): ProviderConfig { + return { + baseUrl: MOONSHOT_BASE_URL, + api: "openai-completions", + models: [ + { + id: MOONSHOT_DEFAULT_MODEL_ID, + name: "Kimi K2.5", + reasoning: false, + input: ["text", "image"], + cost: MOONSHOT_DEFAULT_COST, + contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, + maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + +export function buildKimiCodingProvider(): ProviderConfig { + return { + baseUrl: KIMI_CODING_BASE_URL, + api: "anthropic-messages", + models: [ + { + id: KIMI_CODING_DEFAULT_MODEL_ID, + name: "Kimi for Coding", + reasoning: true, + input: ["text", "image"], + cost: KIMI_CODING_DEFAULT_COST, + contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, + maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, + compat: { + requiresOpenAiAnthropicToolPayload: true, + }, + }, + ], + }; +} + +export function buildQwenPortalProvider(): ProviderConfig { + return { + baseUrl: QWEN_PORTAL_BASE_URL, + api: "openai-completions", + models: [ + { + id: "coder-model", + name: "Qwen Coder", + reasoning: false, + input: ["text"], + cost: QWEN_PORTAL_DEFAULT_COST, + contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, + }, + { + id: "vision-model", + name: "Qwen Vision", + reasoning: false, + input: ["text", "image"], + cost: QWEN_PORTAL_DEFAULT_COST, + contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + +export function buildSyntheticProvider(): ProviderConfig { + return { + baseUrl: SYNTHETIC_BASE_URL, + api: "anthropic-messages", + models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), + }; +} + +export function buildDoubaoProvider(): ProviderConfig { + return { + baseUrl: DOUBAO_BASE_URL, + api: "openai-completions", + models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition), + }; +} + +export function buildDoubaoCodingProvider(): ProviderConfig { + return { + baseUrl: DOUBAO_CODING_BASE_URL, + api: "openai-completions", + models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition), + }; +} + +export function buildBytePlusProvider(): ProviderConfig { + return { + baseUrl: BYTEPLUS_BASE_URL, + api: "openai-completions", + models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition), + }; +} + +export function buildBytePlusCodingProvider(): ProviderConfig { + return { + baseUrl: BYTEPLUS_CODING_BASE_URL, + api: "openai-completions", + models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition), + }; +} + +export function buildXiaomiProvider(): ProviderConfig { + return { + baseUrl: XIAOMI_BASE_URL, + api: "anthropic-messages", + models: [ + { + id: XIAOMI_DEFAULT_MODEL_ID, + name: "Xiaomi MiMo V2 Flash", + reasoning: false, + input: ["text"], + cost: XIAOMI_DEFAULT_COST, + contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, + maxTokens: XIAOMI_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + +export function buildTogetherProvider(): ProviderConfig { + return { + baseUrl: TOGETHER_BASE_URL, + api: "openai-completions", + models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition), + }; +} + +export function buildOpenrouterProvider(): ProviderConfig { + return { + baseUrl: OPENROUTER_BASE_URL, + api: "openai-completions", + models: [ + { + id: OPENROUTER_DEFAULT_MODEL_ID, + name: "OpenRouter Auto", + reasoning: false, + input: ["text", "image"], + cost: OPENROUTER_DEFAULT_COST, + contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW, + maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS, + }, + ], + }; +} + +export function buildOpenAICodexProvider(): ProviderConfig { + return { + baseUrl: OPENAI_CODEX_BASE_URL, + api: "openai-codex-responses", + models: [], + }; +} + +export function buildQianfanProvider(): ProviderConfig { + return { + baseUrl: QIANFAN_BASE_URL, + api: "openai-completions", + models: [ + { + id: QIANFAN_DEFAULT_MODEL_ID, + name: "DEEPSEEK V3.2", + reasoning: true, + input: ["text"], + cost: QIANFAN_DEFAULT_COST, + contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW, + maxTokens: QIANFAN_DEFAULT_MAX_TOKENS, + }, + { + id: "ernie-5.0-thinking-preview", + name: "ERNIE-5.0-Thinking-Preview", + reasoning: true, + input: ["text", "image"], + cost: QIANFAN_DEFAULT_COST, + contextWindow: 119000, + maxTokens: 64000, + }, + ], + }; +} + +export function buildNvidiaProvider(): ProviderConfig { + return { + baseUrl: NVIDIA_BASE_URL, + api: "openai-completions", + models: [ + { + id: NVIDIA_DEFAULT_MODEL_ID, + name: "NVIDIA Llama 3.1 Nemotron 70B Instruct", + reasoning: false, + input: ["text"], + cost: NVIDIA_DEFAULT_COST, + contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW, + maxTokens: NVIDIA_DEFAULT_MAX_TOKENS, + }, + { + id: "meta/llama-3.3-70b-instruct", + name: "Meta Llama 3.3 70B Instruct", + reasoning: false, + input: ["text"], + cost: NVIDIA_DEFAULT_COST, + contextWindow: 131072, + maxTokens: 4096, + }, + { + id: "nvidia/mistral-nemo-minitron-8b-8k-instruct", + name: "NVIDIA Mistral NeMo Minitron 8B Instruct", + reasoning: false, + input: ["text"], + cost: NVIDIA_DEFAULT_COST, + contextWindow: 8192, + maxTokens: 2048, + }, + ], + }; +} + +export function buildKilocodeProvider(): ProviderConfig { + return { + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + models: KILOCODE_MODEL_CATALOG.map((model) => ({ + id: model.id, + name: model.name, + reasoning: model.reasoning, + input: model.input, + cost: KILOCODE_DEFAULT_COST, + contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS, + })), + }; +} diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 9f6316ecc0216..48be848dcbe16 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -6,34 +6,14 @@ import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken, } from "../providers/github-copilot-token.js"; -import { - KILOCODE_BASE_URL, - KILOCODE_DEFAULT_CONTEXT_WINDOW, - KILOCODE_DEFAULT_COST, - KILOCODE_DEFAULT_MAX_TOKENS, - KILOCODE_MODEL_CATALOG, -} from "../providers/kilocode-shared.js"; +import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; -import { - buildBytePlusModelDefinition, - BYTEPLUS_BASE_URL, - BYTEPLUS_MODEL_CATALOG, - BYTEPLUS_CODING_BASE_URL, - BYTEPLUS_CODING_MODEL_CATALOG, -} from "./byteplus-models.js"; import { buildCloudflareAiGatewayModelDefinition, resolveCloudflareAiGatewayBaseUrl, } from "./cloudflare-ai-gateway.js"; -import { - buildDoubaoModelDefinition, - DOUBAO_BASE_URL, - DOUBAO_MODEL_CATALOG, - DOUBAO_CODING_BASE_URL, - DOUBAO_CODING_MODEL_CATALOG, -} from "./doubao-models.js"; import { discoverHuggingfaceModels, HUGGINGFACE_BASE_URL, @@ -41,6 +21,38 @@ import { buildHuggingfaceModelDefinition, } from "./huggingface-models.js"; import { discoverKilocodeModels } from "./kilocode-models.js"; +import { + buildBytePlusCodingProvider, + buildBytePlusProvider, + buildDoubaoCodingProvider, + buildDoubaoProvider, + buildKimiCodingProvider, + buildKilocodeProvider, + buildMinimaxPortalProvider, + buildMinimaxProvider, + buildMoonshotProvider, + buildNvidiaProvider, + buildOpenAICodexProvider, + buildOpenrouterProvider, + buildQianfanProvider, + buildQwenPortalProvider, + buildSyntheticProvider, + buildTogetherProvider, + buildXiaomiProvider, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, + XIAOMI_DEFAULT_MODEL_ID, +} from "./models-config.providers.static.js"; +export { + buildKimiCodingProvider, + buildKilocodeProvider, + buildNvidiaProvider, + buildQianfanProvider, + buildXiaomiProvider, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, + XIAOMI_DEFAULT_MODEL_ID, +} from "./models-config.providers.static.js"; import { MINIMAX_OAUTH_MARKER, OLLAMA_LOCAL_AUTH_MARKER, @@ -52,105 +64,12 @@ import { } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; -import { - buildSyntheticModelDefinition, - SYNTHETIC_BASE_URL, - SYNTHETIC_MODEL_CATALOG, -} from "./synthetic-models.js"; -import { - TOGETHER_BASE_URL, - TOGETHER_MODEL_CATALOG, - buildTogetherModelDefinition, -} from "./together-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; -const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; -const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; -const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; -const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; -const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -// Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price -const MINIMAX_API_COST = { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0.12, -}; - -type ProviderModelConfig = NonNullable[number]; - -function buildMinimaxModel(params: { - id: string; - name: string; - reasoning: boolean; - input: ProviderModelConfig["input"]; -}): ProviderModelConfig { - return { - id: params.id, - name: params.name, - reasoning: params.reasoning, - input: params.input, - cost: MINIMAX_API_COST, - contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW, - maxTokens: MINIMAX_DEFAULT_MAX_TOKENS, - }; -} - -function buildMinimaxTextModel(params: { - id: string; - name: string; - reasoning: boolean; -}): ProviderModelConfig { - return buildMinimaxModel({ ...params, input: ["text"] }); -} - -const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic"; -export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash"; -const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144; -const XIAOMI_DEFAULT_MAX_TOKENS = 8192; -const XIAOMI_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; -const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; -const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; -const MOONSHOT_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; -const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5"; -const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; -const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; -const KIMI_CODING_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; -const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; -const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; -const QWEN_PORTAL_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL; const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL; const OLLAMA_SHOW_CONCURRENCY = 8; @@ -164,17 +83,6 @@ const OLLAMA_DEFAULT_COST = { cacheWrite: 0, }; -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; -const OPENROUTER_DEFAULT_MODEL_ID = "auto"; -const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000; -const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; -const OPENROUTER_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - const VLLM_BASE_URL = "http://127.0.0.1:8000/v1"; const VLLM_DEFAULT_CONTEXT_WINDOW = 128000; const VLLM_DEFAULT_MAX_TOKENS = 8192; @@ -185,30 +93,6 @@ const VLLM_DEFAULT_COST = { cacheWrite: 0, }; -export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2"; -export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2"; -const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304; -const QIANFAN_DEFAULT_MAX_TOKENS = 32768; -const QIANFAN_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; -const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; -const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072; -const NVIDIA_DEFAULT_MAX_TOKENS = 4096; -const NVIDIA_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - const log = createSubsystemLogger("agents/model-providers"); interface OllamaModel { @@ -757,182 +641,6 @@ export function normalizeProviders(params: { return mutated ? next : providers; } -function buildMinimaxProvider(): ProviderConfig { - return { - baseUrl: MINIMAX_PORTAL_BASE_URL, - api: "anthropic-messages", - authHeader: true, - models: [ - buildMinimaxModel({ - id: MINIMAX_DEFAULT_VISION_MODEL_ID, - name: "MiniMax VL 01", - reasoning: false, - input: ["text", "image"], - }), - buildMinimaxTextModel({ - id: "MiniMax-M2.5", - name: "MiniMax M2.5", - reasoning: true, - }), - buildMinimaxTextModel({ - id: "MiniMax-M2.5-highspeed", - name: "MiniMax M2.5 Highspeed", - reasoning: true, - }), - ], - }; -} - -function buildMinimaxPortalProvider(): ProviderConfig { - return { - baseUrl: MINIMAX_PORTAL_BASE_URL, - api: "anthropic-messages", - authHeader: true, - models: [ - buildMinimaxModel({ - id: MINIMAX_DEFAULT_VISION_MODEL_ID, - name: "MiniMax VL 01", - reasoning: false, - input: ["text", "image"], - }), - buildMinimaxTextModel({ - id: MINIMAX_DEFAULT_MODEL_ID, - name: "MiniMax M2.5", - reasoning: true, - }), - buildMinimaxTextModel({ - id: "MiniMax-M2.5-highspeed", - name: "MiniMax M2.5 Highspeed", - reasoning: true, - }), - ], - }; -} - -function buildMoonshotProvider(): ProviderConfig { - return { - baseUrl: MOONSHOT_BASE_URL, - api: "openai-completions", - models: [ - { - id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2.5", - reasoning: false, - input: ["text", "image"], - cost: MOONSHOT_DEFAULT_COST, - contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, - maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -export function buildKimiCodingProvider(): ProviderConfig { - return { - baseUrl: KIMI_CODING_BASE_URL, - api: "anthropic-messages", - models: [ - { - id: KIMI_CODING_DEFAULT_MODEL_ID, - name: "Kimi for Coding", - reasoning: true, - input: ["text", "image"], - cost: KIMI_CODING_DEFAULT_COST, - contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, - maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, - compat: { - requiresOpenAiAnthropicToolPayload: true, - }, - }, - ], - }; -} - -function buildQwenPortalProvider(): ProviderConfig { - return { - baseUrl: QWEN_PORTAL_BASE_URL, - api: "openai-completions", - models: [ - { - id: "coder-model", - name: "Qwen Coder", - reasoning: false, - input: ["text"], - cost: QWEN_PORTAL_DEFAULT_COST, - contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, - }, - { - id: "vision-model", - name: "Qwen Vision", - reasoning: false, - input: ["text", "image"], - cost: QWEN_PORTAL_DEFAULT_COST, - contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -function buildSyntheticProvider(): ProviderConfig { - return { - baseUrl: SYNTHETIC_BASE_URL, - api: "anthropic-messages", - models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), - }; -} - -function buildDoubaoProvider(): ProviderConfig { - return { - baseUrl: DOUBAO_BASE_URL, - api: "openai-completions", - models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition), - }; -} - -function buildDoubaoCodingProvider(): ProviderConfig { - return { - baseUrl: DOUBAO_CODING_BASE_URL, - api: "openai-completions", - models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition), - }; -} - -function buildBytePlusProvider(): ProviderConfig { - return { - baseUrl: BYTEPLUS_BASE_URL, - api: "openai-completions", - models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition), - }; -} - -function buildBytePlusCodingProvider(): ProviderConfig { - return { - baseUrl: BYTEPLUS_CODING_BASE_URL, - api: "openai-completions", - models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition), - }; -} - -export function buildXiaomiProvider(): ProviderConfig { - return { - baseUrl: XIAOMI_BASE_URL, - api: "anthropic-messages", - models: [ - { - id: XIAOMI_DEFAULT_MODEL_ID, - name: "Xiaomi MiMo V2 Flash", - reasoning: false, - input: ["text"], - cost: XIAOMI_DEFAULT_COST, - contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW, - maxTokens: XIAOMI_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - async function buildVeniceProvider(): Promise { const models = await discoverVeniceModels(); return { @@ -975,48 +683,6 @@ async function buildVercelAiGatewayProvider(): Promise { }; } -function buildTogetherProvider(): ProviderConfig { - return { - baseUrl: TOGETHER_BASE_URL, - api: "openai-completions", - models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition), - }; -} - -function buildOpenrouterProvider(): ProviderConfig { - return { - baseUrl: OPENROUTER_BASE_URL, - api: "openai-completions", - models: [ - { - 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, - contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW, - maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS, - }, - ], - }; -} - -function buildOpenAICodexProvider(): ProviderConfig { - return { - baseUrl: OPENAI_CODEX_BASE_URL, - api: "openai-codex-responses", - // Like Copilot, Codex resolves OAuth credentials from auth-profiles at - // runtime, so the snapshot only needs the canonical API surface. - models: [], - }; -} - async function buildVllmProvider(params?: { baseUrl?: string; apiKey?: string; @@ -1030,85 +696,6 @@ async function buildVllmProvider(params?: { }; } -export function buildQianfanProvider(): ProviderConfig { - return { - baseUrl: QIANFAN_BASE_URL, - api: "openai-completions", - models: [ - { - id: QIANFAN_DEFAULT_MODEL_ID, - name: "DEEPSEEK V3.2", - reasoning: true, - input: ["text"], - cost: QIANFAN_DEFAULT_COST, - contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW, - maxTokens: QIANFAN_DEFAULT_MAX_TOKENS, - }, - { - id: "ernie-5.0-thinking-preview", - name: "ERNIE-5.0-Thinking-Preview", - reasoning: true, - input: ["text", "image"], - cost: QIANFAN_DEFAULT_COST, - contextWindow: 119000, - maxTokens: 64000, - }, - ], - }; -} - -export function buildNvidiaProvider(): ProviderConfig { - return { - baseUrl: NVIDIA_BASE_URL, - api: "openai-completions", - models: [ - { - id: NVIDIA_DEFAULT_MODEL_ID, - name: "NVIDIA Llama 3.1 Nemotron 70B Instruct", - reasoning: false, - input: ["text"], - cost: NVIDIA_DEFAULT_COST, - contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW, - maxTokens: NVIDIA_DEFAULT_MAX_TOKENS, - }, - { - id: "meta/llama-3.3-70b-instruct", - name: "Meta Llama 3.3 70B Instruct", - reasoning: false, - input: ["text"], - cost: NVIDIA_DEFAULT_COST, - contextWindow: 131072, - maxTokens: 4096, - }, - { - id: "nvidia/mistral-nemo-minitron-8b-8k-instruct", - name: "NVIDIA Mistral NeMo Minitron 8B Instruct", - reasoning: false, - input: ["text"], - cost: NVIDIA_DEFAULT_COST, - contextWindow: 8192, - maxTokens: 2048, - }, - ], - }; -} - -export function buildKilocodeProvider(): ProviderConfig { - return { - baseUrl: KILOCODE_BASE_URL, - api: "openai-completions", - models: KILOCODE_MODEL_CATALOG.map((model) => ({ - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: model.input, - cost: KILOCODE_DEFAULT_COST, - contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW, - maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS, - })), - }; -} - /** * Build the Kilocode provider with dynamic model discovery from the gateway * API. Falls back to the static catalog on failure. From 52bc809143c641bdb7f2a66b2d321a9c640bd140 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:12:58 +0000 Subject: [PATCH 109/311] refactor: extract provider stream wrappers --- .../anthropic-stream-wrappers.ts | 319 ++++++++ src/agents/pi-embedded-runner/extra-params.ts | 696 +----------------- .../moonshot-stream-wrappers.ts | 113 +++ .../proxy-stream-wrappers.ts | 145 ++++ 4 files changed, 597 insertions(+), 676 deletions(-) create mode 100644 src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts create mode 100644 src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts create mode 100644 src/agents/pi-embedded-runner/proxy-stream-wrappers.ts diff --git a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts new file mode 100644 index 0000000000000..77c5e82f81409 --- /dev/null +++ b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts @@ -0,0 +1,319 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple } from "@mariozechner/pi-ai"; +import { + requiresOpenAiCompatibleAnthropicToolPayload, + usesOpenAiFunctionAnthropicToolSchema, + usesOpenAiStringModeAnthropicToolChoice, +} from "../provider-capabilities.js"; +import { log } from "./logger.js"; + +const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07"; +const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const; +const PI_AI_DEFAULT_ANTHROPIC_BETAS = [ + "fine-grained-tool-streaming-2025-05-14", + "interleaved-thinking-2025-05-14", +] as const; +const PI_AI_OAUTH_ANTHROPIC_BETAS = [ + "claude-code-20250219", + "oauth-2025-04-20", + ...PI_AI_DEFAULT_ANTHROPIC_BETAS, +] as const; + +type CacheRetention = "none" | "short" | "long"; + +function isAnthropic1MModel(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase(); + return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + +function parseHeaderList(value: unknown): string[] { + if (typeof value !== "string") { + return []; + } + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function mergeAnthropicBetaHeader( + headers: Record | undefined, + betas: string[], +): Record { + const merged = { ...headers }; + const existingKey = Object.keys(merged).find((key) => key.toLowerCase() === "anthropic-beta"); + const existing = existingKey ? parseHeaderList(merged[existingKey]) : []; + const values = Array.from(new Set([...existing, ...betas])); + const key = existingKey ?? "anthropic-beta"; + merged[key] = values.join(","); + return merged; +} + +function isAnthropicOAuthApiKey(apiKey: unknown): boolean { + return typeof apiKey === "string" && apiKey.includes("sk-ant-oat"); +} + +function requiresAnthropicToolPayloadCompatibilityForModel(model: { + api?: unknown; + provider?: unknown; + compat?: unknown; +}): boolean { + if (model.api !== "anthropic-messages") { + return false; + } + + if ( + typeof model.provider === "string" && + requiresOpenAiCompatibleAnthropicToolPayload(model.provider) + ) { + return true; + } + + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; + } + + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + +function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { + provider?: unknown; + compat?: unknown; +}): boolean { + if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { + return true; + } + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; + } + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + +function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { + provider?: unknown; + compat?: unknown; +}): boolean { + if ( + typeof model.provider === "string" && + usesOpenAiStringModeAnthropicToolChoice(model.provider) + ) { + return true; + } + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; + } + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + +function normalizeOpenAiFunctionAnthropicToolDefinition( + tool: unknown, +): Record | undefined { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + return undefined; + } + + const toolObj = tool as Record; + if (toolObj.function && typeof toolObj.function === "object") { + return toolObj; + } + + const rawName = typeof toolObj.name === "string" ? toolObj.name.trim() : ""; + if (!rawName) { + return toolObj; + } + + const functionSpec: Record = { + name: rawName, + parameters: + toolObj.input_schema && typeof toolObj.input_schema === "object" + ? toolObj.input_schema + : toolObj.parameters && typeof toolObj.parameters === "object" + ? toolObj.parameters + : { type: "object", properties: {} }, + }; + + if (typeof toolObj.description === "string" && toolObj.description.trim()) { + functionSpec.description = toolObj.description; + } + if (typeof toolObj.strict === "boolean") { + functionSpec.strict = toolObj.strict; + } + + return { + type: "function", + function: functionSpec, + }; +} + +function normalizeOpenAiStringModeAnthropicToolChoice(toolChoice: unknown): unknown { + if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) { + return toolChoice; + } + + const choice = toolChoice as Record; + if (choice.type === "auto") { + return "auto"; + } + if (choice.type === "none") { + return "none"; + } + if (choice.type === "required" || choice.type === "any") { + return "required"; + } + if (choice.type === "tool" && typeof choice.name === "string" && choice.name.trim()) { + return { + type: "function", + function: { name: choice.name.trim() }, + }; + } + + return toolChoice; +} + +export function resolveCacheRetention( + extraParams: Record | undefined, + provider: string, +): CacheRetention | undefined { + const isAnthropicDirect = provider === "anthropic"; + const hasBedrockOverride = + extraParams?.cacheRetention !== undefined || extraParams?.cacheControlTtl !== undefined; + const isAnthropicBedrock = provider === "amazon-bedrock" && hasBedrockOverride; + + if (!isAnthropicDirect && !isAnthropicBedrock) { + return undefined; + } + + const newVal = extraParams?.cacheRetention; + if (newVal === "none" || newVal === "short" || newVal === "long") { + return newVal; + } + + const legacy = extraParams?.cacheControlTtl; + if (legacy === "5m") { + return "short"; + } + if (legacy === "1h") { + return "long"; + } + + return isAnthropicDirect ? "short" : undefined; +} + +export function resolveAnthropicBetas( + extraParams: Record | undefined, + provider: string, + modelId: string, +): string[] | undefined { + if (provider !== "anthropic") { + return undefined; + } + + const betas = new Set(); + const configured = extraParams?.anthropicBeta; + if (typeof configured === "string" && configured.trim()) { + betas.add(configured.trim()); + } else if (Array.isArray(configured)) { + for (const beta of configured) { + if (typeof beta === "string" && beta.trim()) { + betas.add(beta.trim()); + } + } + } + + if (extraParams?.context1m === true) { + if (isAnthropic1MModel(modelId)) { + betas.add(ANTHROPIC_CONTEXT_1M_BETA); + } else { + log.warn(`ignoring context1m for non-opus/sonnet model: ${provider}/${modelId}`); + } + } + + return betas.size > 0 ? [...betas] : undefined; +} + +export function createAnthropicBetaHeadersWrapper( + baseStreamFn: StreamFn | undefined, + betas: string[], +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const isOauth = isAnthropicOAuthApiKey(options?.apiKey); + const requestedContext1m = betas.includes(ANTHROPIC_CONTEXT_1M_BETA); + const effectiveBetas = + isOauth && requestedContext1m + ? betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA) + : betas; + if (isOauth && requestedContext1m) { + log.warn( + `ignoring context1m for OAuth token auth on ${model.provider}/${model.id}; Anthropic rejects context-1m beta with OAuth auth`, + ); + } + + const piAiBetas = isOauth + ? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[]) + : (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]); + const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])]; + return underlying(model, context, { + ...options, + headers: mergeAnthropicBetaHeader(options?.headers, allBetas), + }); + }; +} + +export function createAnthropicToolPayloadCompatibilityWrapper( + 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" && + requiresAnthropicToolPayloadCompatibilityForModel(model) + ) { + const payloadObj = payload as Record; + if ( + Array.isArray(payloadObj.tools) && + usesOpenAiFunctionAnthropicToolSchemaForModel(model) + ) { + payloadObj.tools = payloadObj.tools + .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) + .filter((tool): tool is Record => !!tool); + } + if (usesOpenAiStringModeAnthropicToolChoiceForModel(model)) { + payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( + payloadObj.tool_choice, + ); + } + } + originalOnPayload?.(payload); + }, + }); + }; +} + +export function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => + underlying(model, context, { + ...options, + cacheRetention: "none", + }); +} + +export function isAnthropicBedrockModel(modelId: string): boolean { + const normalized = modelId.toLowerCase(); + return normalized.includes("anthropic.claude") || normalized.includes("anthropic/claude"); +} diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 89451dc614dbf..7054d765f81a4 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -4,11 +4,20 @@ import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import { - requiresOpenAiCompatibleAnthropicToolPayload, - usesOpenAiFunctionAnthropicToolSchema, - usesOpenAiStringModeAnthropicToolChoice, -} from "../provider-capabilities.js"; + createAnthropicBetaHeadersWrapper, + createAnthropicToolPayloadCompatibilityWrapper, + createBedrockNoCacheWrapper, + isAnthropicBedrockModel, + resolveAnthropicBetas, + resolveCacheRetention, +} from "./anthropic-stream-wrappers.js"; import { log } from "./logger.js"; +import { + createMoonshotThinkingWrapper, + createSiliconFlowThinkingWrapper, + resolveMoonshotThinkingType, + shouldApplySiliconFlowThinkingOffCompat, +} from "./moonshot-stream-wrappers.js"; import { createCodexDefaultTransportWrapper, createOpenAIDefaultTransportWrapper, @@ -16,22 +25,13 @@ import { createOpenAIServiceTierWrapper, resolveOpenAIServiceTier, } from "./openai-stream-wrappers.js"; +import { + createKilocodeWrapper, + createOpenRouterSystemCacheWrapper, + createOpenRouterWrapper, + isProxyReasoningUnsupported, +} from "./proxy-stream-wrappers.js"; -const OPENROUTER_APP_HEADERS: Record = { - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw", -}; -const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; -const KILOCODE_FEATURE_DEFAULT = "openclaw"; -const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE"; - -function resolveKilocodeAppHeaders(): Record { - const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT; - return { [KILOCODE_FEATURE_HEADER]: feature }; -} - -const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07"; -const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const; /** * Resolve provider-specific extra params from model config. * Used to pass through stream params like temperature/maxTokens. @@ -70,65 +70,11 @@ export function resolveExtraParams(params: { return merged; } -type CacheRetention = "none" | "short" | "long"; type CacheRetentionStreamOptions = Partial & { - cacheRetention?: CacheRetention; + cacheRetention?: "none" | "short" | "long"; openaiWsWarmup?: boolean; }; -/** - * Resolve cacheRetention from extraParams, supporting both new `cacheRetention` - * and legacy `cacheControlTtl` values for backwards compatibility. - * - * Mapping: "5m" → "short", "1h" → "long" - * - * Applies to: - * - direct Anthropic provider - * - Anthropic Claude models on Bedrock when cache retention is explicitly configured - * - * OpenRouter uses openai-completions API with hardcoded cache_control instead - * of the cacheRetention stream option. - * - * Defaults to "short" for direct Anthropic when not explicitly configured. - */ -function resolveCacheRetention( - extraParams: Record | undefined, - provider: string, -): CacheRetention | undefined { - const isAnthropicDirect = provider === "anthropic"; - const hasBedrockOverride = - extraParams?.cacheRetention !== undefined || extraParams?.cacheControlTtl !== undefined; - const isAnthropicBedrock = provider === "amazon-bedrock" && hasBedrockOverride; - - if (!isAnthropicDirect && !isAnthropicBedrock) { - return undefined; - } - - // Prefer new cacheRetention if present - const newVal = extraParams?.cacheRetention; - if (newVal === "none" || newVal === "short" || newVal === "long") { - return newVal; - } - - // Fall back to legacy cacheControlTtl with mapping - const legacy = extraParams?.cacheControlTtl; - if (legacy === "5m") { - return "short"; - } - if (legacy === "1h") { - return "long"; - } - - // Default to "short" only for direct Anthropic when not explicitly configured. - // Bedrock retains upstream provider defaults unless explicitly set. - if (!isAnthropicDirect) { - return undefined; - } - - // Default to "short" for direct Anthropic when not explicitly configured - return "short"; -} - function createStreamFnWithExtraParams( baseStreamFn: StreamFn | undefined, extraParams: Record | undefined, @@ -201,608 +147,6 @@ function createStreamFnWithExtraParams( return wrappedStreamFn; } -function isAnthropicBedrockModel(modelId: string): boolean { - const normalized = modelId.toLowerCase(); - return normalized.includes("anthropic.claude") || normalized.includes("anthropic/claude"); -} - -function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => - underlying(model, context, { - ...options, - cacheRetention: "none", - }); -} - -function isAnthropic1MModel(modelId: string): boolean { - const normalized = modelId.trim().toLowerCase(); - return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); -} - -function parseHeaderList(value: unknown): string[] { - if (typeof value !== "string") { - return []; - } - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -function resolveAnthropicBetas( - extraParams: Record | undefined, - provider: string, - modelId: string, -): string[] | undefined { - if (provider !== "anthropic") { - return undefined; - } - - const betas = new Set(); - const configured = extraParams?.anthropicBeta; - if (typeof configured === "string" && configured.trim()) { - betas.add(configured.trim()); - } else if (Array.isArray(configured)) { - for (const beta of configured) { - if (typeof beta === "string" && beta.trim()) { - betas.add(beta.trim()); - } - } - } - - if (extraParams?.context1m === true) { - if (isAnthropic1MModel(modelId)) { - betas.add(ANTHROPIC_CONTEXT_1M_BETA); - } else { - log.warn(`ignoring context1m for non-opus/sonnet model: ${provider}/${modelId}`); - } - } - - return betas.size > 0 ? [...betas] : undefined; -} - -function mergeAnthropicBetaHeader( - headers: Record | undefined, - betas: string[], -): Record { - const merged = { ...headers }; - const existingKey = Object.keys(merged).find((key) => key.toLowerCase() === "anthropic-beta"); - const existing = existingKey ? parseHeaderList(merged[existingKey]) : []; - const values = Array.from(new Set([...existing, ...betas])); - const key = existingKey ?? "anthropic-beta"; - merged[key] = values.join(","); - return merged; -} - -// Betas that pi-ai's createClient injects for standard Anthropic API key calls. -// Must be included when injecting anthropic-beta via options.headers, because -// pi-ai's mergeHeaders uses Object.assign (last-wins), which would otherwise -// overwrite the hardcoded defaultHeaders["anthropic-beta"]. -const PI_AI_DEFAULT_ANTHROPIC_BETAS = [ - "fine-grained-tool-streaming-2025-05-14", - "interleaved-thinking-2025-05-14", -] as const; - -// Additional betas pi-ai injects when the API key is an OAuth token (sk-ant-oat-*). -// These are required for Anthropic to accept OAuth Bearer auth. Losing oauth-2025-04-20 -// causes a 401 "OAuth authentication is currently not supported". -const PI_AI_OAUTH_ANTHROPIC_BETAS = [ - "claude-code-20250219", - "oauth-2025-04-20", - ...PI_AI_DEFAULT_ANTHROPIC_BETAS, -] as const; - -function isAnthropicOAuthApiKey(apiKey: unknown): boolean { - return typeof apiKey === "string" && apiKey.includes("sk-ant-oat"); -} - -function createAnthropicBetaHeadersWrapper( - baseStreamFn: StreamFn | undefined, - betas: string[], -): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - const isOauth = isAnthropicOAuthApiKey(options?.apiKey); - const requestedContext1m = betas.includes(ANTHROPIC_CONTEXT_1M_BETA); - const effectiveBetas = - isOauth && requestedContext1m - ? betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA) - : betas; - if (isOauth && requestedContext1m) { - log.warn( - `ignoring context1m for OAuth token auth on ${model.provider}/${model.id}; Anthropic rejects context-1m beta with OAuth auth`, - ); - } - - // Preserve the betas pi-ai's createClient would inject for the given token type. - // Without this, our options.headers["anthropic-beta"] overwrites the pi-ai - // defaultHeaders via Object.assign, stripping critical betas like oauth-2025-04-20. - const piAiBetas = isOauth - ? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[]) - : (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]); - const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])]; - return underlying(model, context, { - ...options, - headers: mergeAnthropicBetaHeader(options?.headers, allBetas), - }); - }; -} - -function isOpenRouterAnthropicModel(provider: string, modelId: string): boolean { - return provider.toLowerCase() === "openrouter" && modelId.toLowerCase().startsWith("anthropic/"); -} - -type PayloadMessage = { - role?: string; - content?: unknown; -}; - -/** - * Inject cache_control into the system message for OpenRouter Anthropic models. - * OpenRouter passes through Anthropic's cache_control field — caching the system - * prompt avoids re-processing it on every request. - */ -function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - if ( - typeof model.provider !== "string" || - typeof model.id !== "string" || - !isOpenRouterAnthropicModel(model.provider, model.id) - ) { - return underlying(model, context, options); - } - - const originalOnPayload = options?.onPayload; - return underlying(model, context, { - ...options, - onPayload: (payload) => { - const messages = (payload as Record)?.messages; - if (Array.isArray(messages)) { - for (const msg of messages as PayloadMessage[]) { - if (msg.role !== "system" && msg.role !== "developer") { - continue; - } - if (typeof msg.content === "string") { - msg.content = [ - { type: "text", text: msg.content, cache_control: { type: "ephemeral" } }, - ]; - } else if (Array.isArray(msg.content) && msg.content.length > 0) { - const last = msg.content[msg.content.length - 1]; - if (last && typeof last === "object") { - (last as Record).cache_control = { type: "ephemeral" }; - } - } - } - } - originalOnPayload?.(payload); - }, - }); - }; -} - -/** - * Map OpenClaw's ThinkLevel to OpenRouter's reasoning.effort values. - * "off" maps to "none"; all other levels pass through as-is. - */ -function mapThinkingLevelToOpenRouterReasoningEffort( - thinkingLevel: ThinkLevel, -): "none" | "minimal" | "low" | "medium" | "high" | "xhigh" { - if (thinkingLevel === "off") { - return "none"; - } - if (thinkingLevel === "adaptive") { - return "medium"; - } - 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); - }, - }); - }; -} - -type MoonshotThinkingType = "enabled" | "disabled"; - -function normalizeMoonshotThinkingType(value: unknown): MoonshotThinkingType | undefined { - if (typeof value === "boolean") { - return value ? "enabled" : "disabled"; - } - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if ( - normalized === "enabled" || - normalized === "enable" || - normalized === "on" || - normalized === "true" - ) { - return "enabled"; - } - if ( - normalized === "disabled" || - normalized === "disable" || - normalized === "off" || - normalized === "false" - ) { - return "disabled"; - } - return undefined; - } - if (value && typeof value === "object" && !Array.isArray(value)) { - const typeValue = (value as Record).type; - return normalizeMoonshotThinkingType(typeValue); - } - return undefined; -} - -function resolveMoonshotThinkingType(params: { - configuredThinking: unknown; - thinkingLevel?: ThinkLevel; -}): MoonshotThinkingType | undefined { - const configured = normalizeMoonshotThinkingType(params.configuredThinking); - if (configured) { - return configured; - } - if (!params.thinkingLevel) { - return undefined; - } - return params.thinkingLevel === "off" ? "disabled" : "enabled"; -} - -function isMoonshotToolChoiceCompatible(toolChoice: unknown): boolean { - if (toolChoice == null) { - return true; - } - if (toolChoice === "auto" || toolChoice === "none") { - return true; - } - if (typeof toolChoice === "object" && !Array.isArray(toolChoice)) { - const typeValue = (toolChoice as Record).type; - return typeValue === "auto" || typeValue === "none"; - } - return false; -} - -/** - * Moonshot Kimi supports native binary thinking mode: - * - { thinking: { type: "enabled" } } - * - { thinking: { type: "disabled" } } - * - * When thinking is enabled, Moonshot only accepts tool_choice auto|none. - * Normalize incompatible values to auto instead of failing the request. - */ -function createMoonshotThinkingWrapper( - baseStreamFn: StreamFn | undefined, - thinkingType?: MoonshotThinkingType, -): 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; - let effectiveThinkingType = normalizeMoonshotThinkingType(payloadObj.thinking); - - if (thinkingType) { - payloadObj.thinking = { type: thinkingType }; - effectiveThinkingType = thinkingType; - } - - if ( - effectiveThinkingType === "enabled" && - !isMoonshotToolChoiceCompatible(payloadObj.tool_choice) - ) { - payloadObj.tool_choice = "auto"; - } - } - originalOnPayload?.(payload); - }, - }); - }; -} - -function requiresAnthropicToolPayloadCompatibilityForModel(model: { - api?: unknown; - provider?: unknown; - compat?: unknown; -}): boolean { - if (model.api !== "anthropic-messages") { - return false; - } - - if ( - typeof model.provider === "string" && - requiresOpenAiCompatibleAnthropicToolPayload(model.provider) - ) { - return true; - } - - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); -} - -function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { - provider?: unknown; - compat?: unknown; -}): boolean { - if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { - return true; - } - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); -} - -function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { - provider?: unknown; - compat?: unknown; -}): boolean { - if ( - typeof model.provider === "string" && - usesOpenAiStringModeAnthropicToolChoice(model.provider) - ) { - return true; - } - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); -} - -function normalizeOpenAiFunctionAnthropicToolDefinition( - tool: unknown, -): Record | undefined { - if (!tool || typeof tool !== "object" || Array.isArray(tool)) { - return undefined; - } - - const toolObj = tool as Record; - if (toolObj.function && typeof toolObj.function === "object") { - return toolObj; - } - - const rawName = typeof toolObj.name === "string" ? toolObj.name.trim() : ""; - if (!rawName) { - return toolObj; - } - - const functionSpec: Record = { - name: rawName, - parameters: - toolObj.input_schema && typeof toolObj.input_schema === "object" - ? toolObj.input_schema - : toolObj.parameters && typeof toolObj.parameters === "object" - ? toolObj.parameters - : { type: "object", properties: {} }, - }; - - if (typeof toolObj.description === "string" && toolObj.description.trim()) { - functionSpec.description = toolObj.description; - } - if (typeof toolObj.strict === "boolean") { - functionSpec.strict = toolObj.strict; - } - - return { - type: "function", - function: functionSpec, - }; -} - -function normalizeOpenAiStringModeAnthropicToolChoice(toolChoice: unknown): unknown { - if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) { - return toolChoice; - } - - const choice = toolChoice as Record; - if (choice.type === "auto") { - return "auto"; - } - if (choice.type === "none") { - return "none"; - } - if (choice.type === "required") { - return "required"; - } - if (choice.type === "any") { - return "required"; - } - if (choice.type === "tool" && typeof choice.name === "string" && choice.name.trim()) { - return { - type: "function", - function: { name: choice.name.trim() }, - }; - } - - return toolChoice; -} - -/** - * Some anthropic-messages providers accept Anthropic framing but still expect - * OpenAI-style tool payloads (`tools[].function`, string tool_choice modes). - */ -function createAnthropicToolPayloadCompatibilityWrapper( - 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" && - requiresAnthropicToolPayloadCompatibilityForModel(model) - ) { - const payloadObj = payload as Record; - if ( - Array.isArray(payloadObj.tools) && - usesOpenAiFunctionAnthropicToolSchemaForModel(model) - ) { - payloadObj.tools = payloadObj.tools - .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) - .filter((tool): tool is Record => !!tool); - } - if (usesOpenAiStringModeAnthropicToolChoiceForModel(model)) { - payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( - payloadObj.tool_choice, - ); - } - } - originalOnPayload?.(payload); - }, - }); - }; -} - -/** - * Create a streamFn wrapper that adds OpenRouter app attribution headers - * and injects reasoning.effort based on the configured thinking level. - */ -function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkLevel): void { - if (!payload || typeof payload !== "object") { - return; - } - - const payloadObj = payload as Record; - - // pi-ai may inject a top-level reasoning_effort (OpenAI flat format). - // OpenRouter-compatible proxy gateways expect the nested reasoning.effort - // shape instead, and some models reject the flat field outright. - delete payloadObj.reasoning_effort; - - // When thinking is "off", or provider/model guards disable injection, - // leave reasoning unset after normalizing away the legacy flat field. - if (!thinkingLevel || thinkingLevel === "off") { - return; - } - - 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), - }; - } -} - -function createOpenRouterWrapper( - baseStreamFn: StreamFn | undefined, - thinkingLevel?: ThinkLevel, -): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - const onPayload = options?.onPayload; - return underlying(model, context, { - ...options, - headers: { - ...OPENROUTER_APP_HEADERS, - ...options?.headers, - }, - onPayload: (payload) => { - normalizeProxyReasoningPayload(payload, thinkingLevel); - onPayload?.(payload); - }, - }); - }; -} - -/** - * Models on OpenRouter-style proxy providers that reject `reasoning.effort`. - */ -function isProxyReasoningUnsupported(modelId: string): boolean { - const id = modelId.toLowerCase(); - return id.startsWith("x-ai/"); -} - -/** - * Create a streamFn wrapper that adds the Kilocode feature attribution header - * and injects reasoning.effort based on the configured thinking level. - * - * The Kilocode provider gateway manages provider-specific quirks (e.g. cache - * control) server-side, so we only handle header injection and reasoning here. - */ -function createKilocodeWrapper( - baseStreamFn: StreamFn | undefined, - thinkingLevel?: ThinkLevel, -): StreamFn { - const underlying = baseStreamFn ?? streamSimple; - return (model, context, options) => { - const onPayload = options?.onPayload; - return underlying(model, context, { - ...options, - headers: { - ...options?.headers, - ...resolveKilocodeAppHeaders(), - }, - onPayload: (payload) => { - normalizeProxyReasoningPayload(payload, thinkingLevel); - onPayload?.(payload); - }, - }); - }; -} - function isGemini31Model(modelId: string): boolean { const normalized = modelId.toLowerCase(); return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash"); diff --git a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts new file mode 100644 index 0000000000000..0cb17c6d49e38 --- /dev/null +++ b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts @@ -0,0 +1,113 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple } from "@mariozechner/pi-ai"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; + +type MoonshotThinkingType = "enabled" | "disabled"; + +function normalizeMoonshotThinkingType(value: unknown): MoonshotThinkingType | undefined { + if (typeof value === "boolean") { + return value ? "enabled" : "disabled"; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["enabled", "enable", "on", "true"].includes(normalized)) { + return "enabled"; + } + if (["disabled", "disable", "off", "false"].includes(normalized)) { + return "disabled"; + } + return undefined; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + return normalizeMoonshotThinkingType((value as Record).type); + } + return undefined; +} + +function isMoonshotToolChoiceCompatible(toolChoice: unknown): boolean { + if (toolChoice == null || toolChoice === "auto" || toolChoice === "none") { + return true; + } + if (typeof toolChoice === "object" && !Array.isArray(toolChoice)) { + const typeValue = (toolChoice as Record).type; + return typeValue === "auto" || typeValue === "none"; + } + return false; +} + +export function shouldApplySiliconFlowThinkingOffCompat(params: { + provider: string; + modelId: string; + thinkingLevel?: ThinkLevel; +}): boolean { + return ( + params.provider === "siliconflow" && + params.thinkingLevel === "off" && + params.modelId.startsWith("Pro/") + ); +} + +export 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); + }, + }); + }; +} + +export function resolveMoonshotThinkingType(params: { + configuredThinking: unknown; + thinkingLevel?: ThinkLevel; +}): MoonshotThinkingType | undefined { + const configured = normalizeMoonshotThinkingType(params.configuredThinking); + if (configured) { + return configured; + } + if (!params.thinkingLevel) { + return undefined; + } + return params.thinkingLevel === "off" ? "disabled" : "enabled"; +} + +export function createMoonshotThinkingWrapper( + baseStreamFn: StreamFn | undefined, + thinkingType?: MoonshotThinkingType, +): 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; + let effectiveThinkingType = normalizeMoonshotThinkingType(payloadObj.thinking); + + if (thinkingType) { + payloadObj.thinking = { type: thinkingType }; + effectiveThinkingType = thinkingType; + } + + if ( + effectiveThinkingType === "enabled" && + !isMoonshotToolChoiceCompatible(payloadObj.tool_choice) + ) { + payloadObj.tool_choice = "auto"; + } + } + originalOnPayload?.(payload); + }, + }); + }; +} diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts new file mode 100644 index 0000000000000..5e8076ad49c46 --- /dev/null +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts @@ -0,0 +1,145 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple } from "@mariozechner/pi-ai"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; + +const OPENROUTER_APP_HEADERS: Record = { + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw", +}; +const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; +const KILOCODE_FEATURE_DEFAULT = "openclaw"; +const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE"; + +function resolveKilocodeAppHeaders(): Record { + const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT; + return { [KILOCODE_FEATURE_HEADER]: feature }; +} + +function isOpenRouterAnthropicModel(provider: string, modelId: string): boolean { + return provider.toLowerCase() === "openrouter" && modelId.toLowerCase().startsWith("anthropic/"); +} + +function mapThinkingLevelToOpenRouterReasoningEffort( + thinkingLevel: ThinkLevel, +): "none" | "minimal" | "low" | "medium" | "high" | "xhigh" { + if (thinkingLevel === "off") { + return "none"; + } + if (thinkingLevel === "adaptive") { + return "medium"; + } + return thinkingLevel; +} + +function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkLevel): void { + if (!payload || typeof payload !== "object") { + return; + } + + const payloadObj = payload as Record; + delete payloadObj.reasoning_effort; + if (!thinkingLevel || thinkingLevel === "off") { + return; + } + + const existingReasoning = payloadObj.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), + }; + } +} + +export function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + if ( + typeof model.provider !== "string" || + typeof model.id !== "string" || + !isOpenRouterAnthropicModel(model.provider, model.id) + ) { + return underlying(model, context, options); + } + + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + const messages = (payload as Record)?.messages; + if (Array.isArray(messages)) { + for (const msg of messages as Array<{ role?: string; content?: unknown }>) { + if (msg.role !== "system" && msg.role !== "developer") { + continue; + } + if (typeof msg.content === "string") { + msg.content = [ + { type: "text", text: msg.content, cache_control: { type: "ephemeral" } }, + ]; + } else if (Array.isArray(msg.content) && msg.content.length > 0) { + const last = msg.content[msg.content.length - 1]; + if (last && typeof last === "object") { + (last as Record).cache_control = { type: "ephemeral" }; + } + } + } + } + originalOnPayload?.(payload); + }, + }); + }; +} + +export function createOpenRouterWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel?: ThinkLevel, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const onPayload = options?.onPayload; + return underlying(model, context, { + ...options, + headers: { + ...OPENROUTER_APP_HEADERS, + ...options?.headers, + }, + onPayload: (payload) => { + normalizeProxyReasoningPayload(payload, thinkingLevel); + onPayload?.(payload); + }, + }); + }; +} + +export function isProxyReasoningUnsupported(modelId: string): boolean { + return modelId.toLowerCase().startsWith("x-ai/"); +} + +export function createKilocodeWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel?: ThinkLevel, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const onPayload = options?.onPayload; + return underlying(model, context, { + ...options, + headers: { + ...options?.headers, + ...resolveKilocodeAppHeaders(), + }, + onPayload: (payload) => { + normalizeProxyReasoningPayload(payload, thinkingLevel); + onPayload?.(payload); + }, + }); + }; +} From d307a7ca1a21b5650f74efa732368663c733a33d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:15:16 +0000 Subject: [PATCH 110/311] refactor: extract bundled extension manifest parser --- scripts/lib/bundled-extension-manifest.ts | 71 +++++++++++++++++++++ scripts/release-check.ts | 76 +++-------------------- 2 files changed, 79 insertions(+), 68 deletions(-) create mode 100644 scripts/lib/bundled-extension-manifest.ts diff --git a/scripts/lib/bundled-extension-manifest.ts b/scripts/lib/bundled-extension-manifest.ts new file mode 100644 index 0000000000000..07053e943eb7c --- /dev/null +++ b/scripts/lib/bundled-extension-manifest.ts @@ -0,0 +1,71 @@ +export type ExtensionPackageJson = { + name?: string; + version?: string; + dependencies?: Record; + optionalDependencies?: Record; + openclaw?: { + install?: { + npmSpec?: string; + }; + releaseChecks?: { + rootDependencyMirrorAllowlist?: string[]; + }; + }; +}; + +export type BundledExtension = { id: string; packageJson: ExtensionPackageJson }; +export type BundledExtensionMetadata = BundledExtension & { + npmSpec?: string; + rootDependencyMirrorAllowlist: string[]; +}; + +export function normalizeBundledExtensionMetadata( + extensions: BundledExtension[], +): BundledExtensionMetadata[] { + return extensions.map((extension) => ({ + ...extension, + npmSpec: + typeof extension.packageJson.openclaw?.install?.npmSpec === "string" + ? extension.packageJson.openclaw.install.npmSpec.trim() + : undefined, + rootDependencyMirrorAllowlist: + extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ) ?? [], + })); +} + +export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] { + const errors: string[] = []; + + for (const extension of extensions) { + const install = extension.packageJson.openclaw?.install; + if ( + install && + (!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim()) + ) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`, + ); + } + + const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; + if (allowlist === undefined) { + continue; + } + if (!Array.isArray(allowlist)) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`, + ); + continue; + } + const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim()); + if (invalidEntries.length > 0) { + errors.push( + `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`, + ); + } + } + + return errors; +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index df79b69f25c2b..fe2a9a1ea9c11 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -4,29 +4,18 @@ import { execSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { + collectBundledExtensionManifestErrors, + normalizeBundledExtensionMetadata, + type BundledExtension, + type ExtensionPackageJson as PackageJson, +} from "./lib/bundled-extension-manifest.ts"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; +export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; + type PackFile = { path: string }; type PackResult = { files?: PackFile[] }; -type PackageJson = { - name?: string; - version?: string; - dependencies?: Record; - optionalDependencies?: Record; - openclaw?: { - install?: { - npmSpec?: string; - }; - releaseChecks?: { - rootDependencyMirrorAllowlist?: string[]; - }; - }; -}; -type BundledExtension = { id: string; packageJson: PackageJson }; -type BundledExtensionMetadata = BundledExtension & { - npmSpec?: string; - rootDependencyMirrorAllowlist: string[]; -}; const requiredPathGroups = [ ["dist/index.js", "dist/index.mjs"], @@ -175,55 +164,6 @@ export function collectBundledExtensionRootDependencyGapErrors(params: { return errors; } -function normalizeBundledExtensionMetadata( - extensions: BundledExtension[], -): BundledExtensionMetadata[] { - return extensions.map((extension) => ({ - ...extension, - npmSpec: - typeof extension.packageJson.openclaw?.install?.npmSpec === "string" - ? extension.packageJson.openclaw.install.npmSpec.trim() - : undefined, - rootDependencyMirrorAllowlist: - extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ) ?? [], - })); -} - -export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] { - const errors: string[] = []; - for (const extension of extensions) { - const install = extension.packageJson.openclaw?.install; - if ( - install && - (!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim()) - ) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`, - ); - } - - const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; - if (allowlist === undefined) { - continue; - } - if (!Array.isArray(allowlist)) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`, - ); - continue; - } - const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim()); - if (invalidEntries.length > 0) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`, - ); - } - } - return errors; -} - function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => From 8ab762c005f11f05fa51dcc7dfeab2e43cb4fbd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:17:51 +0000 Subject: [PATCH 111/311] test: standardize hermetic provider env snapshots --- src/agents/models-config.e2e-harness.ts | 26 +++++++++++++++++++ ...s-config.providers.auth-provenance.test.ts | 8 +++--- ...ig.providers.cloudflare-ai-gateway.test.ts | 6 ++--- ...ls-config.providers.discovery-auth.test.ts | 8 +++--- .../models-config.providers.kilocode.test.ts | 7 ++--- ...odels-config.providers.kimi-coding.test.ts | 7 ++--- .../models-config.providers.minimax.test.ts | 4 +-- .../models-config.providers.nvidia.test.ts | 15 ++++++----- ...fig.providers.ollama-autodiscovery.test.ts | 8 +++--- .../models-config.providers.ollama.test.ts | 15 ++++++----- ...dels-config.providers.openai-codex.test.ts | 4 +-- .../models-config.providers.qianfan.test.ts | 4 +-- ...config.providers.vercel-ai-gateway.test.ts | 8 +++--- ...nfig.providers.volcengine-byteplus.test.ts | 8 +++--- 14 files changed, 79 insertions(+), 49 deletions(-) diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 4bd6d47960cf2..71577b27e69b8 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import type { OpenClawConfig } from "../config/config.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; export async function withModelsTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-models-" }); @@ -106,6 +107,8 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "TOGETHER_API_KEY", "VOLCANO_ENGINE_API_KEY", "BYTEPLUS_API_KEY", + "KILOCODE_API_KEY", + "KIMI_API_KEY", "KIMICODE_API_KEY", "GEMINI_API_KEY", "VENICE_API_KEY", @@ -123,6 +126,29 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "AWS_SHARED_CREDENTIALS_FILE", ]; +export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const source = env ?? process.env; + const snapshot: NodeJS.ProcessEnv = {}; + + for (const envVar of MODELS_CONFIG_IMPLICIT_ENV_VARS) { + const value = source[envVar]; + if (value !== undefined) { + snapshot[envVar] = value; + } + } + + return snapshot; +} + +export async function resolveImplicitProvidersForTest( + params: Parameters[0], +) { + return await resolveImplicitProviders({ + ...params, + env: snapshotImplicitProviderEnv(params.env), + }); +} + export const CUSTOM_PROXY_MODELS_CONFIG: OpenClawConfig = { models: { providers: { diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts index c06653973f3bc..987f825932be7 100644 --- a/src/agents/models-config.providers.auth-provenance.test.ts +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -9,7 +9,7 @@ import { NON_ENV_SECRETREF_MARKER, QWEN_OAUTH_MARKER, } from "./model-auth-markers.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; describe("models-config provider auth provenance", () => { it("persists env keyRef and tokenRef auth profiles as env var markers", async () => { @@ -41,7 +41,7 @@ describe("models-config provider auth provenance", () => { "utf8", ); try { - const providers = await resolveImplicitProviders({ agentDir, env: {} }); + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); @@ -78,7 +78,7 @@ describe("models-config provider auth provenance", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir, env: {} }); + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); @@ -114,7 +114,7 @@ describe("models-config provider auth provenance", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir, env: {} }); + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER); }); diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts index 82a16dbcbee65..dad90c740d2ce 100644 --- a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; describe("cloudflare-ai-gateway profile provenance", () => { it("prefers env keyRef marker over runtime plaintext for persistence", async () => { @@ -37,7 +37,7 @@ describe("cloudflare-ai-gateway profile provenance", () => { "utf8", ); try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); } finally { envSnapshot.restore(); @@ -70,7 +70,7 @@ describe("cloudflare-ai-gateway profile provenance", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); }); }); diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts index c5c79b2fb9558..e6aebc0d7cb16 100644 --- a/src/agents/models-config.providers.discovery-auth.test.ts +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; describe("provider discovery auth marker guardrails", () => { let originalVitest: string | undefined; @@ -63,7 +63,7 @@ describe("provider discovery auth marker guardrails", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir, env: {} }); + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); const request = fetchMock.mock.calls[0]?.[1] as | { headers?: Record } @@ -96,7 +96,7 @@ describe("provider discovery auth marker guardrails", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir, env: {} }); + const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} }); expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) => String(url).includes("router.huggingface.co"), @@ -132,7 +132,7 @@ describe("provider discovery auth marker guardrails", () => { "utf8", ); - await resolveImplicitProviders({ agentDir, env: {} }); + await resolveImplicitProvidersForTest({ agentDir, env: {} }); const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000")); const request = vllmCall?.[1] as { headers?: Record } | undefined; expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE"); diff --git a/src/agents/models-config.providers.kilocode.test.ts b/src/agents/models-config.providers.kilocode.test.ts index ce57ab561be3b..18edb78b2a6e1 100644 --- a/src/agents/models-config.providers.kilocode.test.ts +++ b/src/agents/models-config.providers.kilocode.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; -import { buildKilocodeProvider, resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { buildKilocodeProvider } from "./models-config.providers.js"; const KILOCODE_MODEL_IDS = ["kilo/auto"]; @@ -14,7 +15,7 @@ describe("Kilo Gateway implicit provider", () => { process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.kilocode).toBeDefined(); expect(providers?.kilocode?.models?.length).toBeGreaterThan(0); } finally { @@ -28,7 +29,7 @@ describe("Kilo Gateway implicit provider", () => { delete process.env.KILOCODE_API_KEY; try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.kilocode).toBeUndefined(); } finally { envSnapshot.restore(); diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts index bc49115cb405a..33e94a2f1c33f 100644 --- a/src/agents/models-config.providers.kimi-coding.test.ts +++ b/src/agents/models-config.providers.kimi-coding.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; -import { buildKimiCodingProvider, resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { buildKimiCodingProvider } from "./models-config.providers.js"; describe("kimi-coding implicit provider (#22409)", () => { it("should include kimi-coding when KIMI_API_KEY is configured", async () => { @@ -12,7 +13,7 @@ describe("kimi-coding implicit provider (#22409)", () => { process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["kimi-coding"]).toBeDefined(); expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages"); expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/"); @@ -36,7 +37,7 @@ describe("kimi-coding implicit provider (#22409)", () => { delete process.env.KIMI_API_KEY; try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["kimi-coding"]).toBeUndefined(); } finally { envSnapshot.restore(); diff --git a/src/agents/models-config.providers.minimax.test.ts b/src/agents/models-config.providers.minimax.test.ts index 687020f756818..80718d28fbecd 100644 --- a/src/agents/models-config.providers.minimax.test.ts +++ b/src/agents/models-config.providers.minimax.test.ts @@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; describe("minimax provider catalog", () => { it("does not advertise the removed lightning model for api-key or oauth providers", async () => { @@ -34,7 +34,7 @@ describe("minimax provider catalog", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([ "MiniMax-VL-01", "MiniMax-M2.5", diff --git a/src/agents/models-config.providers.nvidia.test.ts b/src/agents/models-config.providers.nvidia.test.ts index fe61b3433694d..11a291bf69f6c 100644 --- a/src/agents/models-config.providers.nvidia.test.ts +++ b/src/agents/models-config.providers.nvidia.test.ts @@ -5,13 +5,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveApiKeyForProvider } from "./model-auth.js"; -import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { buildNvidiaProvider } from "./models-config.providers.js"; describe("NVIDIA provider", () => { it("should include nvidia when NVIDIA_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); await withEnvAsync({ NVIDIA_API_KEY: "test-key" }, async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.nvidia).toBeDefined(); expect(providers?.nvidia?.models?.length).toBeGreaterThan(0); }); @@ -52,7 +53,7 @@ describe("MiniMax implicit provider (#15275)", () => { it("should use anthropic-messages API for API-key provider", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); await withEnvAsync({ MINIMAX_API_KEY: "test-key" }, async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.minimax).toBeDefined(); expect(providers?.minimax?.api).toBe("anthropic-messages"); expect(providers?.minimax?.authHeader).toBe(true); @@ -83,14 +84,14 @@ describe("MiniMax implicit provider (#15275)", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["minimax-portal"]?.authHeader).toBe(true); }); it("should include minimax portal provider when MINIMAX_OAUTH_TOKEN is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); await withEnvAsync({ MINIMAX_OAUTH_TOKEN: "portal-token" }, async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["minimax-portal"]).toBeDefined(); expect(providers?.["minimax-portal"]?.authHeader).toBe(true); expect(providers?.["minimax-portal"]?.models?.some((m) => m.id === "MiniMax-VL-01")).toBe( @@ -104,7 +105,7 @@ describe("vLLM provider", () => { it("should not include vllm when no API key is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); await withEnvAsync({ VLLM_API_KEY: undefined }, async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.vllm).toBeUndefined(); }); }); @@ -112,7 +113,7 @@ describe("vLLM provider", () => { it("should include vllm when VLLM_API_KEY is set", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); await withEnvAsync({ VLLM_API_KEY: "test-key" }, async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.vllm).toBeDefined(); expect(providers?.vllm?.apiKey).toBe("VLLM_API_KEY"); diff --git a/src/agents/models-config.providers.ollama-autodiscovery.test.ts b/src/agents/models-config.providers.ollama-autodiscovery.test.ts index b878607edeaf5..b550e19d40cb9 100644 --- a/src/agents/models-config.providers.ollama-autodiscovery.test.ts +++ b/src/agents/models-config.providers.ollama-autodiscovery.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; describe("Ollama auto-discovery", () => { let originalVitest: string | undefined; @@ -55,7 +55,7 @@ describe("Ollama auto-discovery", () => { }) as unknown as typeof fetch; const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.ollama).toBeDefined(); expect(providers?.ollama?.apiKey).toBe("ollama-local"); @@ -73,7 +73,7 @@ describe("Ollama auto-discovery", () => { mockOllamaUnreachable(); const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.ollama).toBeUndefined(); const ollamaWarnings = warnSpy.mock.calls.filter( @@ -89,7 +89,7 @@ describe("Ollama auto-discovery", () => { mockOllamaUnreachable(); const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await resolveImplicitProviders({ + await resolveImplicitProvidersForTest({ agentDir, explicitProviders: { ollama: { diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index 661c95c1c8e5a..49e4deae5513a 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { resolveImplicitProviders, resolveOllamaApiBase } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { resolveOllamaApiBase } from "./models-config.providers.js"; afterEach(() => { vi.unstubAllEnvs(); @@ -60,7 +61,7 @@ describe("Ollama provider", () => { } async function resolveProvidersWithOllamaKey(agentDir: string) { - return await withOllamaApiKey(async () => await resolveImplicitProviders({ agentDir })); + return await withOllamaApiKey(async () => await resolveImplicitProvidersForTest({ agentDir })); } const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" }); @@ -78,7 +79,7 @@ describe("Ollama provider", () => { it("should not include ollama when no API key is configured", async () => { const agentDir = createAgentDir(); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.ollama).toBeUndefined(); }); @@ -86,7 +87,7 @@ describe("Ollama provider", () => { it("should use native ollama api type", async () => { const agentDir = createAgentDir(); await withOllamaApiKey(async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.ollama).toBeDefined(); expect(providers?.ollama?.apiKey).toBe("OLLAMA_API_KEY"); @@ -98,7 +99,7 @@ describe("Ollama provider", () => { it("should preserve explicit ollama baseUrl on implicit provider injection", async () => { const agentDir = createAgentDir(); await withOllamaApiKey(async () => { - const providers = await resolveImplicitProviders({ + const providers = await resolveImplicitProvidersForTest({ agentDir, explicitProviders: { ollama: { @@ -239,7 +240,7 @@ describe("Ollama provider", () => { }, ]; - const providers = await resolveImplicitProviders({ + const providers = await resolveImplicitProvidersForTest({ agentDir, explicitProviders: { ollama: { @@ -264,7 +265,7 @@ describe("Ollama provider", () => { it("should preserve explicit apiKey when discovery path has no models and no env key", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const providers = await resolveImplicitProviders({ + const providers = await resolveImplicitProvidersForTest({ agentDir, explicitProviders: { ollama: { diff --git a/src/agents/models-config.providers.openai-codex.test.ts b/src/agents/models-config.providers.openai-codex.test.ts index 98051eb05dadc..89add15433a38 100644 --- a/src/agents/models-config.providers.openai-codex.test.ts +++ b/src/agents/models-config.providers.openai-codex.test.ts @@ -5,12 +5,12 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { installModelsConfigTestHooks, MODELS_CONFIG_IMPLICIT_ENV_VARS, + resolveImplicitProvidersForTest, unsetEnv, withModelsTempHome, withTempEnv, } from "./models-config.e2e-harness.js"; import { ensureOpenClawModelsJson } from "./models-config.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; import { readGeneratedModelsJson } from "./models-config.test-utils.js"; installModelsConfigTestHooks(); @@ -50,7 +50,7 @@ describe("openai-codex implicit provider", () => { const agentDir = resolveOpenClawAgentDir(); await writeCodexOauthProfile(agentDir); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["openai-codex"]).toMatchObject({ baseUrl: "https://chatgpt.com/backend-api", api: "openai-codex-responses", diff --git a/src/agents/models-config.providers.qianfan.test.ts b/src/agents/models-config.providers.qianfan.test.ts index 97c093d7c47d2..da55cd4420681 100644 --- a/src/agents/models-config.providers.qianfan.test.ts +++ b/src/agents/models-config.providers.qianfan.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_"); @@ -13,7 +13,7 @@ describe("Qianfan provider", () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const qianfanApiKey = "test-key"; // pragma: allowlist secret await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.qianfan).toBeDefined(); expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY"); }); diff --git a/src/agents/models-config.providers.vercel-ai-gateway.test.ts b/src/agents/models-config.providers.vercel-ai-gateway.test.ts index c4caf98ce1178..d53e2f8543597 100644 --- a/src/agents/models-config.providers.vercel-ai-gateway.test.ts +++ b/src/agents/models-config.providers.vercel-ai-gateway.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; import { VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; describe("vercel-ai-gateway provider resolution", () => { @@ -14,7 +14,7 @@ describe("vercel-ai-gateway provider resolution", () => { process.env.AI_GATEWAY_API_KEY = "vercel-gateway-test-key"; // pragma: allowlist secret try { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); const provider = providers?.["vercel-ai-gateway"]; expect(provider?.apiKey).toBe("AI_GATEWAY_API_KEY"); expect(provider?.api).toBe("anthropic-messages"); @@ -52,7 +52,7 @@ describe("vercel-ai-gateway provider resolution", () => { ); try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["vercel-ai-gateway"]?.apiKey).toBe("AI_GATEWAY_API_KEY"); } finally { envSnapshot.restore(); @@ -81,7 +81,7 @@ describe("vercel-ai-gateway provider resolution", () => { "utf8", ); - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["vercel-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); }); }); diff --git a/src/agents/models-config.providers.volcengine-byteplus.test.ts b/src/agents/models-config.providers.volcengine-byteplus.test.ts index cba285210406a..16a0d8d259a8e 100644 --- a/src/agents/models-config.providers.volcengine-byteplus.test.ts +++ b/src/agents/models-config.providers.volcengine-byteplus.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { upsertAuthProfile } from "./auth-profiles.js"; -import { resolveImplicitProviders } from "./models-config.providers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; describe("Volcengine and BytePlus providers", () => { it("includes volcengine and volcengine-plan when VOLCANO_ENGINE_API_KEY is configured", async () => { @@ -13,7 +13,7 @@ describe("Volcengine and BytePlus providers", () => { process.env.VOLCANO_ENGINE_API_KEY = "test-key"; // pragma: allowlist secret try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.volcengine).toBeDefined(); expect(providers?.["volcengine-plan"]).toBeDefined(); expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); @@ -29,7 +29,7 @@ describe("Volcengine and BytePlus providers", () => { process.env.BYTEPLUS_API_KEY = "test-key"; // pragma: allowlist secret try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.byteplus).toBeDefined(); expect(providers?.["byteplus-plan"]).toBeDefined(); expect(providers?.byteplus?.apiKey).toBe("BYTEPLUS_API_KEY"); @@ -65,7 +65,7 @@ describe("Volcengine and BytePlus providers", () => { }); try { - const providers = await resolveImplicitProviders({ agentDir }); + const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.byteplus?.apiKey).toBe("BYTEPLUS_API_KEY"); From 72ebaf97c309d25ff6102ba900ad3992d8b04737 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:19:02 +0000 Subject: [PATCH 112/311] test: add implicit provider matrix coverage --- .../models-config.providers.matrix.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/agents/models-config.providers.matrix.test.ts diff --git a/src/agents/models-config.providers.matrix.test.ts b/src/agents/models-config.providers.matrix.test.ts new file mode 100644 index 0000000000000..ec2b743afb68b --- /dev/null +++ b/src/agents/models-config.providers.matrix.test.ts @@ -0,0 +1,175 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + MINIMAX_OAUTH_MARKER, + NON_ENV_SECRETREF_MARKER, + OLLAMA_LOCAL_AUTH_MARKER, +} from "./model-auth-markers.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; + +type ProvidersMap = Awaited>; +type ExplicitProviders = NonNullable["providers"]>; +type MatrixCase = { + name: string; + env?: NodeJS.ProcessEnv; + authProfiles?: Record; + explicitProviders?: ExplicitProviders; + assertProviders: (providers: ProvidersMap) => void; +}; + +async function writeAuthProfiles( + agentDir: string, + profiles: Record | undefined, +): Promise { + if (!profiles) { + return; + } + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify({ version: 1, profiles }, null, 2), + "utf8", + ); +} + +const MATRIX_CASES: MatrixCase[] = [ + { + name: "env api key injects a simple provider", + env: { NVIDIA_API_KEY: "test-nvidia-key" }, + assertProviders(providers) { + expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY"); + expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1"); + expect(providers?.nvidia?.models?.length).toBeGreaterThan(0); + }, + }, + { + name: "env api key injects paired plan providers", + env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, + assertProviders(providers) { + expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.["volcengine-plan"]?.api).toBe("openai-completions"); + }, + }, + { + name: "env-backed auth profiles persist env markers", + env: {}, + authProfiles: { + "together:default": { + type: "token", + provider: "together", + tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" }, + }, + }, + assertProviders(providers) { + expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); + }, + }, + { + name: "non-env secret refs preserve compatibility markers", + env: {}, + authProfiles: { + "byteplus:default": { + type: "api_key", + provider: "byteplus", + key: "runtime-byteplus-key", + keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" }, + }, + }, + assertProviders(providers) { + expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }, + }, + { + name: "oauth profiles still inject compatibility providers", + env: {}, + authProfiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "codex-access-token", + refresh: "codex-refresh-token", + expires: Date.now() + 60_000, + }, + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "minimax-access-token", + refresh: "minimax-refresh-token", + expires: Date.now() + 60_000, + }, + }, + assertProviders(providers) { + expect(providers?.["openai-codex"]).toMatchObject({ + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-codex-responses", + models: [], + }); + expect(providers?.["openai-codex"]).not.toHaveProperty("apiKey"); + expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); + }, + }, + { + name: "explicit vllm config suppresses implicit vllm injection", + env: { VLLM_API_KEY: "test-vllm-key" }, + explicitProviders: { + vllm: { + baseUrl: "http://127.0.0.1:8000/v1", + api: "openai-completions", + models: [], + }, + }, + assertProviders(providers) { + expect(providers?.vllm).toBeUndefined(); + }, + }, + { + name: "explicit ollama models still normalize the returned provider", + env: {}, + explicitProviders: { + ollama: { + baseUrl: "http://remote-ollama:11434/v1", + models: [ + { + id: "gpt-oss:20b", + name: "GPT-OSS 20B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 81920, + }, + ], + }, + }, + assertProviders(providers) { + expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434"); + expect(providers?.ollama?.api).toBe("ollama"); + expect(providers?.ollama?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER); + expect(providers?.ollama?.models).toHaveLength(1); + }, + }, +]; + +describe("implicit provider resolution matrix", () => { + it.each(MATRIX_CASES)( + "$name", + async ({ env, authProfiles, explicitProviders, assertProviders }) => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeAuthProfiles(agentDir, authProfiles); + + const providers = await resolveImplicitProvidersForTest({ + agentDir, + env, + explicitProviders, + }); + + assertProviders(providers); + }, + ); +}); From 404b1527e6bdf84a2b976f80111e5e5334c10e7c Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:37:00 +0100 Subject: [PATCH 113/311] fix(acp): persist spawned child session history (#40137) Merged via squash. Prepared head SHA: 62de5d56691e1fd185cc617bb5e7283a14ce90b0 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- .secrets.baseline | 8 +- CHANGELOG.md | 1 + src/agents/acp-spawn.test.ts | 98 +++++++++++++++++++ src/agents/acp-spawn.ts | 85 +++++++++++++++++ src/commands/agent.acp.test.ts | 68 +++++++++++++ src/commands/agent.ts | 153 +++++++++++++++++++++++++----- src/config/sessions/transcript.ts | 53 ++++++++++- 7 files changed, 436 insertions(+), 30 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 33aa3b6cecdb0..be62e5a4ca3b2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -10198,21 +10198,21 @@ "filename": "docs/tools/web.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 131 + "line_number": 135 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 224 + "line_number": 228 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 328 + "line_number": 332 } ], "docs/tts.md": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T18:14:00Z" + "generated_at": "2026-03-08T18:30:57Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 203cb9927df6b..c26849508d415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. +- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. ## 2026.3.7 diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 09f7f3f9bcd2c..0f28b70979238 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => { const initializeSessionMock = vi.fn(); const startAcpSpawnParentStreamRelayMock = vi.fn(); const resolveAcpSpawnStreamLogPathMock = vi.fn(); + const loadSessionStoreMock = vi.fn(); + const resolveStorePathMock = vi.fn(); + const resolveSessionTranscriptFileMock = vi.fn(); const state = { cfg: createDefaultSpawnConfig(), }; @@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => { initializeSessionMock, startAcpSpawnParentStreamRelayMock, resolveAcpSpawnStreamLogPathMock, + loadSessionStoreMock, + resolveStorePathMock, + resolveSessionTranscriptFileMock, state, }; }); @@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), + resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts), + }; +}); + +vi.mock("../config/sessions/transcript.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionTranscriptFile: (params: unknown) => + hoisted.resolveSessionTranscriptFileMock(params), + }; +}); + vi.mock("../acp/control-plane/manager.js", () => { return { getAcpSessionManager: () => ({ @@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => { hoisted.resolveAcpSpawnStreamLogPathMock .mockReset() .mockReturnValue("/tmp/sess-main.acp-stream.jsonl"); + hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json"); + hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { + const store: Record = {}; + return new Proxy(store, { + get(_target, prop) { + if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { + return { sessionId: "sess-123", updatedAt: Date.now() }; + } + return undefined; + }, + }); + }); + hoisted.resolveSessionTranscriptFileMock + .mockReset() + .mockImplementation(async (params: unknown) => { + const typed = params as { threadId?: string }; + const sessionFile = typed.threadId + ? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl` + : "/tmp/agents/codex/sessions/sess-123.jsonl"; + return { + sessionFile, + sessionEntry: { + sessionId: "sess-123", + updatedAt: Date.now(), + sessionFile, + }, + }; + }); }); it("spawns ACP session, binds a new thread, and dispatches initial task", async () => { @@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => { expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); expect(result.runId).toBe("run-1"); expect(result.mode).toBe("session"); + const patchCalls = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .filter((request) => request.method === "sessions.patch"); + expect(patchCalls[0]?.params).toMatchObject({ + key: result.childSessionKey, + spawnedBy: "agent:main:main", + }); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ targetKind: "session", @@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => { mode: "persistent", }), ); + const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map( + (call: unknown[]) => call[0] as { threadId?: string }, + ); + expect(transcriptCalls).toHaveLength(2); + expect(transcriptCalls[0]?.threadId).toBeUndefined(); + expect(transcriptCalls[1]?.threadId).toBe("child-thread"); }); it("does not inline delivery for fresh oneshot ACP runs", async () => { @@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => { expect(result.status).toBe("accepted"); expect(result.mode).toBe("run"); + expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-123", + storePath: "/tmp/codex-sessions.json", + agentId: "codex", + }), + ); const agentCall = hoisted.callGatewayMock.mock.calls .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) .find((request) => request.method === "agent"); @@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => { expect(agentCall?.params?.threadId).toBeUndefined(); }); + it("keeps ACP spawn running when session-file persistence fails", async () => { + hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full")); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + mode: "run", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "default", + agentTo: "telegram:6098642967", + agentThreadId: "1", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); + const agentCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "agent"); + expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey); + }); + it("includes cwd in ACP thread intro banner when provided at spawn time", async () => { const result = await spawnAcpDirect( { diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 829ce2ad53031..c08cca8fcf829 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -23,6 +23,8 @@ import { } from "../channels/thread-bindings-policy.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js"; +import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import { callGateway } from "../gateway/call.js"; import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import { @@ -30,6 +32,7 @@ import { isSessionBindingError, type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import { @@ -38,6 +41,9 @@ import { startAcpSpawnParentStreamRelay, } from "./acp-spawn-parent-stream.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; +import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; + +const log = createSubsystemLogger("agents/acp-spawn"); export const ACP_SPAWN_MODES = ["run", "session"] as const; export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number]; @@ -162,6 +168,50 @@ function summarizeError(err: unknown): string { return "error"; } +function resolveRequesterInternalSessionKey(params: { + cfg: OpenClawConfig; + requesterSessionKey?: string; +}): string { + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + const requesterSessionKey = params.requesterSessionKey?.trim(); + return requesterSessionKey + ? resolveInternalSessionKey({ + key: requesterSessionKey, + alias, + mainKey, + }) + : alias; +} + +async function persistAcpSpawnSessionFileBestEffort(params: { + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore: Record; + storePath: string; + agentId: string; + threadId?: string | number; + stage: "spawn" | "thread-bind"; +}): Promise { + try { + const resolvedSessionFile = await resolveSessionTranscriptFile({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + storePath: params.storePath, + agentId: params.agentId, + threadId: params.threadId, + }); + return resolvedSessionFile.sessionEntry; + } catch (error) { + log.warn( + `ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`, + ); + return params.sessionEntry; + } +} + function resolveConversationIdForThreadBinding(params: { to?: string; threadId?: string | number; @@ -257,6 +307,10 @@ export async function spawnAcpDirect( ctx: SpawnAcpContext, ): Promise { const cfg = loadConfig(); + const requesterInternalKey = resolveRequesterInternalSessionKey({ + cfg, + requesterSessionKey: ctx.agentSessionKey, + }); if (!isAcpEnabledByPolicy(cfg)) { return { status: "forbidden", @@ -346,11 +400,27 @@ export async function spawnAcpDirect( method: "sessions.patch", params: { key: sessionKey, + spawnedBy: requesterInternalKey, ...(params.label ? { label: params.label } : {}), }, timeoutMs: 10_000, }); sessionCreated = true; + const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId }); + const sessionStore = loadSessionStore(storePath); + let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey]; + const sessionId = sessionEntry?.sessionId; + if (sessionId) { + sessionEntry = await persistAcpSpawnSessionFileBestEffort({ + sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId: targetAgentId, + stage: "spawn", + }); + } const initialized = await acpManager.initializeSession({ cfg, sessionKey, @@ -408,6 +478,21 @@ export async function spawnAcpDirect( `Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`, ); } + if (sessionId) { + const boundThreadId = String(binding.conversation.conversationId).trim() || undefined; + if (boundThreadId) { + sessionEntry = await persistAcpSpawnSessionFileBestEffort({ + sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId: targetAgentId, + threadId: boundThreadId, + stage: "thread-bind", + }); + } + } } } catch (err) { await cleanupFailedAcpSpawn({ diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index 9beee4b0010b6..ab8c9da8a6eac 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js"; import * as embeddedModule from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; +import { readSessionMessages } from "../gateway/session-utils.fs.js"; import { onAgentEvent } from "../infra/agent-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { agentCommand } from "./agent.js"; @@ -133,6 +134,17 @@ async function withAcpSessionEnv(fn: () => Promise) { }); } +async function withAcpSessionEnvInfo( + fn: (env: { home: string; storePath: string }) => Promise, +) { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + writeAcpSessionStore(storePath); + mockConfig(home, storePath); + await fn({ home, storePath }); + }); +} + function createRunTurnFromTextDeltas(chunks: string[]) { return vi.fn(async (paramsUnknown: unknown) => { const params = paramsUnknown as { @@ -220,6 +232,62 @@ describe("agentCommand ACP runtime routing", () => { }); }); + it("persists ACP child session history to the transcript store", async () => { + await withAcpSessionEnvInfo(async ({ storePath }) => { + const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); + + const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record< + string, + { sessionFile?: string } + >; + const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile; + const messages = readSessionMessages("acp-session-1", storePath, sessionFile); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: "user", + content: "ping", + }); + expect(messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "ACP_OK" }], + }); + }); + }); + + it("preserves exact ACP transcript text without trimming whitespace", async () => { + await withAcpSessionEnvInfo(async ({ storePath }) => { + const runTurn = createRunTurnFromTextDeltas([" ACP_OK\n"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + await agentCommand({ message: " ping\n", sessionKey: "agent:codex:acp:test" }, runtime); + + const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record< + string, + { sessionFile?: string } + >; + const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile; + const messages = readSessionMessages("acp-session-1", storePath, sessionFile); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: "user", + content: " ping\n", + }); + expect(messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: " ACP_OK\n" }], + }); + }); + }); + it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => { await withAcpSessionEnv(async () => { const { assistantEvents, stop } = subscribeAssistantEvents(); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 828f12a43a8fd..24e62cc899841 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,6 +1,9 @@ +import fs from "node:fs/promises"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../acp/policy.js"; import { toAcpRuntimeError } from "../acp/runtime/errors.js"; +import { resolveAcpSessionCwd } from "../acp/runtime/session-identifiers.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; const log = createSubsystemLogger("commands/agent"); @@ -33,6 +36,7 @@ import { resolveDefaultModelForAgent, resolveThinkingDefault, } from "../agents/model-selection.js"; +import { prepareSessionManagerForRun } from "../agents/pi-embedded-runner/session-manager-init.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js"; @@ -65,15 +69,11 @@ import { } from "../config/config.js"; import { mergeSessionEntry, - parseSessionThreadInfo, - resolveAndPersistSessionFile, resolveAgentIdFromSessionKey, - resolveSessionFilePath, - resolveSessionFilePathOptions, - resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, } from "../config/sessions.js"; +import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import { clearAgentRunContext, emitAgentEvent, @@ -86,6 +86,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; +import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { resolveMessageChannel } from "../utils/message-channel.js"; import { deliverAgentCommandResult } from "./agent/delivery.js"; import { resolveAgentRunContext } from "./agent/run-context.js"; @@ -230,9 +231,92 @@ function createAcpVisibleTextAccumulator() { finalize(): string { return visibleText.trim(); }, + finalizeRaw(): string { + return visibleText; + }, }; } +const ACP_TRANSCRIPT_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +} as const; + +async function persistAcpTurnTranscript(params: { + body: string; + finalText: string; + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore?: Record; + storePath?: string; + sessionAgentId: string; + threadId?: string | number; + sessionCwd: string; +}): Promise { + const promptText = params.body; + const replyText = params.finalText; + if (!promptText && !replyText) { + return params.sessionEntry; + } + + const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + storePath: params.storePath, + agentId: params.sessionAgentId, + threadId: params.threadId, + }); + const hadSessionFile = await fs + .access(sessionFile) + .then(() => true) + .catch(() => false); + const sessionManager = SessionManager.open(sessionFile); + await prepareSessionManagerForRun({ + sessionManager, + sessionFile, + hadSessionFile, + sessionId: params.sessionId, + cwd: params.sessionCwd, + }); + + if (promptText) { + sessionManager.appendMessage({ + role: "user", + content: promptText, + timestamp: Date.now(), + }); + } + + if (replyText) { + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: replyText }], + api: "openai-responses", + provider: "openclaw", + model: "acp-runtime", + usage: ACP_TRANSCRIPT_USAGE, + stopReason: "stop", + timestamp: Date.now(), + }); + } + + emitSessionTranscriptUpdate(sessionFile); + return sessionEntry; +} + function runAgentAttempt(params: { providerOverride: string; modelOverride: string; @@ -421,8 +505,8 @@ async function prepareAgentCommandExecution( opts: AgentCommandOpts & { senderIsOwner: boolean }, runtime: RuntimeEnv, ) { - const message = (opts.message ?? "").trim(); - if (!message) { + const message = opts.message ?? ""; + if (!message.trim()) { throw new Error("Message (--message) is required"); } const body = prependInternalEventContext(message, opts.internalEvents); @@ -732,8 +816,29 @@ async function agentCommandInternal( }, }); + const finalTextRaw = visibleTextAccumulator.finalizeRaw(); + const finalText = visibleTextAccumulator.finalize(); + try { + sessionEntry = await persistAcpTurnTranscript({ + body, + finalText: finalTextRaw, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId, + threadId: opts.threadId, + sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir, + }); + } catch (error) { + log.warn( + `ACP transcript persistence failed for ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const normalizedFinalPayload = normalizeReplyPayload({ - text: visibleTextAccumulator.finalize(), + text: finalText, }); const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : []; const result = { @@ -944,29 +1049,27 @@ async function agentCommandInternal( }); } } - const sessionPathOpts = resolveSessionFilePathOptions({ - agentId: sessionAgentId, - storePath, - }); - let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts); + let sessionFile: string | undefined; if (sessionStore && sessionKey) { - const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId; - const fallbackSessionFile = !sessionEntry?.sessionFile - ? resolveSessionTranscriptPath( - sessionId, - sessionAgentId, - opts.threadId ?? threadIdFromSessionKey, - ) - : undefined; - const resolvedSessionFile = await resolveAndPersistSessionFile({ + const resolvedSessionFile = await resolveSessionTranscriptFile({ sessionId, sessionKey, sessionStore, storePath, sessionEntry, - agentId: sessionPathOpts?.agentId, - sessionsDir: sessionPathOpts?.sessionsDir, - fallbackSessionFile, + agentId: sessionAgentId, + threadId: opts.threadId, + }); + sessionFile = resolvedSessionFile.sessionFile; + sessionEntry = resolvedSessionFile.sessionEntry; + } + if (!sessionFile) { + const resolvedSessionFile = await resolveSessionTranscriptFile({ + sessionId, + sessionKey: sessionKey ?? sessionId, + sessionEntry, + agentId: sessionAgentId, + threadId: opts.threadId, }); sessionFile = resolvedSessionFile.sessionFile; sessionEntry = resolvedSessionFile.sessionEntry; diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 5e3aa0a082e08..e6a8044f5c60e 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -2,7 +2,13 @@ import fs from "node:fs"; import path from "node:path"; import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { resolveDefaultSessionStorePath } from "./paths.js"; +import { parseSessionThreadInfo } from "./delivery-info.js"; +import { + resolveDefaultSessionStorePath, + resolveSessionFilePath, + resolveSessionFilePathOptions, + resolveSessionTranscriptPath, +} from "./paths.js"; import { resolveAndPersistSessionFile } from "./session-file.js"; import { loadSessionStore } from "./store.js"; import type { SessionEntry } from "./types.js"; @@ -79,6 +85,51 @@ async function ensureSessionHeader(params: { }); } +export async function resolveSessionTranscriptFile(params: { + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore?: Record; + storePath?: string; + agentId: string; + threadId?: string | number; +}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> { + const sessionPathOpts = resolveSessionFilePathOptions({ + agentId: params.agentId, + storePath: params.storePath, + }); + let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts); + let sessionEntry = params.sessionEntry; + + if (params.sessionStore && params.storePath) { + const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId; + const fallbackSessionFile = !sessionEntry?.sessionFile + ? resolveSessionTranscriptPath( + params.sessionId, + params.agentId, + params.threadId ?? threadIdFromSessionKey, + ) + : undefined; + const resolvedSessionFile = await resolveAndPersistSessionFile({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + storePath: params.storePath, + sessionEntry, + agentId: sessionPathOpts?.agentId, + sessionsDir: sessionPathOpts?.sessionsDir, + fallbackSessionFile, + }); + sessionFile = resolvedSessionFile.sessionFile; + sessionEntry = resolvedSessionFile.sessionEntry; + } + + return { + sessionFile, + sessionEntry, + }; +} + export async function appendAssistantMessageToSessionTranscript(params: { agentId?: string; sessionKey: string; From 2ed644f5d35f4b8b1a67f7765233fddf20f72ae6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:25:32 +0000 Subject: [PATCH 114/311] fix: require talk resolved payload --- src/gateway/protocol/index.test.ts | 17 +++++++ src/gateway/protocol/schema/channels.ts | 47 ++++++++++++------- .../protocol/talk-config.contract.test.ts | 2 +- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index 3554f32627834..ad452effd1f72 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -99,4 +99,21 @@ describe("validateTalkConfigResult", () => { }), ).toBe(true); }); + + it("rejects normalized talk payloads without talk.resolved", () => { + expect( + validateTalkConfigResult({ + config: { + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-normalized", + }, + }, + }, + }, + }), + ).toBe(false); + }); }); diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index f53c1f5302b6a..ee4d6d1ea1f3e 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -35,27 +35,40 @@ const ResolvedTalkConfigSchema = Type.Object( { additionalProperties: false }, ); +const LegacyTalkConfigSchema = 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(SecretInputSchema), + interruptOnSpeech: Type.Optional(Type.Boolean()), + silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, +); + +const NormalizedTalkConfigSchema = Type.Object( + { + provider: Type.Optional(Type.String()), + providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)), + resolved: ResolvedTalkConfigSchema, + 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(SecretInputSchema), + interruptOnSpeech: Type.Optional(Type.Boolean()), + silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, +); + export const TalkConfigResultSchema = Type.Object( { config: Type.Object( { - talk: Type.Optional( - Type.Object( - { - provider: Type.Optional(Type.String()), - providers: Type.Optional(Type.Record(Type.String(), TalkProviderConfigSchema)), - resolved: Type.Optional(ResolvedTalkConfigSchema), - 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(SecretInputSchema), - interruptOnSpeech: Type.Optional(Type.Boolean()), - silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), - }, - { additionalProperties: false }, - ), - ), + talk: Type.Optional(Type.Union([LegacyTalkConfigSchema, NormalizedTalkConfigSchema])), session: Type.Optional( Type.Object( { diff --git a/src/gateway/protocol/talk-config.contract.test.ts b/src/gateway/protocol/talk-config.contract.test.ts index 59cac413a904f..7d77dbc37ca7c 100644 --- a/src/gateway/protocol/talk-config.contract.test.ts +++ b/src/gateway/protocol/talk-config.contract.test.ts @@ -30,7 +30,7 @@ describe("talk.config contract fixtures", () => { if (fixture.payloadValid) { expect(validateTalkConfigResult(payload)).toBe(true); } else { - expect((payload.config.talk as { resolved?: unknown }).resolved).toBeUndefined(); + expect(validateTalkConfigResult(payload)).toBe(false); } if (!fixture.expectedSelection) { From cee2f3e8b4c0e7d4eb69f554fa51ea635ed9c49f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:26:35 +0000 Subject: [PATCH 115/311] refactor: dedupe android talk config parsing --- .../app/voice/TalkModeGatewayConfig.kt | 55 ++++++++++++++++++- .../ai/openclaw/app/voice/TalkModeManager.kt | 48 ---------------- .../app/voice/TalkModeConfigContractTest.kt | 2 +- .../app/voice/TalkModeConfigParsingTest.kt | 27 ++++++--- 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt index 4293c11389664..58208acc0bbec 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeGatewayConfig.kt @@ -4,9 +4,16 @@ import ai.openclaw.app.normalizeMainKey import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull +internal data class TalkProviderConfigSelection( + val provider: String, + val config: JsonObject, + val normalizedPayload: Boolean, +) + internal data class TalkModeGatewayConfigState( val activeProvider: String, val normalizedPayload: Boolean, @@ -22,6 +29,8 @@ internal data class TalkModeGatewayConfigState( ) internal object TalkModeGatewayConfigParser { + private const val defaultTalkProvider = "elevenlabs" + fun parse( config: JsonObject?, defaultProvider: String, @@ -32,7 +41,7 @@ internal object TalkModeGatewayConfigParser { envKey: String?, ): TalkModeGatewayConfigState { val talk = config?.get("talk").asObjectOrNull() - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = selectTalkProviderConfig(talk) val activeProvider = selection?.provider ?: defaultProvider val activeConfig = selection?.config val sessionCfg = config?.get("session").asObjectOrNull() @@ -48,7 +57,7 @@ internal object TalkModeGatewayConfigParser { activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull() - val silenceTimeoutMs = TalkModeManager.resolvedSilenceTimeoutMs(talk) + val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk) return TalkModeGatewayConfigState( activeProvider = activeProvider, @@ -91,6 +100,48 @@ internal object TalkModeGatewayConfigParser { interruptOnSpeech = null, silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs, ) + + fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { + if (talk == null) return null + selectResolvedTalkProviderConfig(talk)?.let { return it } + val rawProvider = talk["provider"].asStringOrNull() + val rawProviders = talk["providers"].asObjectOrNull() + val hasNormalizedPayload = rawProvider != null || rawProviders != null + if (hasNormalizedPayload) { + return null + } + return TalkProviderConfigSelection( + provider = defaultTalkProvider, + config = talk, + normalizedPayload = false, + ) + } + + fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { + val fallback = TalkDefaults.defaultSilenceTimeoutMs + val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback + if (primitive.isString) return fallback + val timeout = primitive.content.toDoubleOrNull() ?: return fallback + if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { + return fallback + } + return timeout.toLong() + } + + private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? { + val resolved = talk["resolved"].asObjectOrNull() ?: return null + val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null + return TalkProviderConfigSelection( + provider = providerId, + config = resolved["config"].asObjectOrNull() ?: buildJsonObject {}, + normalizedPayload = true, + ) + } + + private fun normalizeTalkProviderId(raw: String?): String? { + val trimmed = raw?.trim()?.lowercase().orEmpty() + return trimmed.takeIf { it.isNotEmpty() } + } } private fun normalizeTalkAliasKey(value: String): String = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 8a3b6fd948d88..07bd8a346f06d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -64,54 +64,6 @@ class TalkModeManager( private const val chatFinalWaitWithSubscribeMs = 45_000L private const val chatFinalWaitWithoutSubscribeMs = 6_000L private const val maxCachedRunCompletions = 128 - - 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() } - } - - private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? { - val resolved = talk["resolved"].asObjectOrNull() ?: return null - val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null - return TalkProviderConfigSelection( - provider = providerId, - config = resolved["config"].asObjectOrNull() ?: buildJsonObject {}, - normalizedPayload = true, - ) - } - - internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? { - if (talk == null) return null - selectResolvedTalkProviderConfig(talk)?.let { return it } - val rawProvider = talk["provider"].asStringOrNull() - val rawProviders = talk["providers"].asObjectOrNull() - val hasNormalizedPayload = rawProvider != null || rawProviders != null - if (hasNormalizedPayload) { - return null - } - return TalkProviderConfigSelection( - provider = defaultTalkProvider, - config = talk, - normalizedPayload = false, - ) - } - - internal fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long { - val fallback = TalkDefaults.defaultSilenceTimeoutMs - val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback - if (primitive.isString) return fallback - val timeout = primitive.content.toDoubleOrNull() ?: return fallback - if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) { - return fallback - } - return timeout.toLong() - } } private val mainHandler = Handler(Looper.getMainLooper()) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt index 16336d6570640..d2df33793e29e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt @@ -38,7 +38,7 @@ class TalkModeConfigContractTest { @Test fun selectionFixtures() { for (fixture in loadFixtures().selectionCases) { - val selection = TalkModeManager.selectTalkProviderConfig(fixture.talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk) val expected = fixture.expectedSelection if (expected == null) { assertNull(fixture.id, selection) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt index 4ccee1cdf69c7..e9c46231961b8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigParsingTest.kt @@ -36,7 +36,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertNotNull(selection) assertEquals("elevenlabs", selection?.provider) assertTrue(selection?.normalizedPayload == true) @@ -61,7 +61,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertEquals(null, selection) } @@ -82,7 +82,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertEquals(null, selection) } @@ -105,7 +105,7 @@ class TalkModeConfigParsingTest { ) .jsonObject - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertEquals(null, selection) } @@ -118,7 +118,7 @@ class TalkModeConfigParsingTest { put("apiKey", legacyApiKey) // pragma: allowlist secret } - val selection = TalkModeManager.selectTalkProviderConfig(talk) + val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk) assertNotNull(selection) assertEquals("elevenlabs", selection?.provider) assertTrue(selection?.normalizedPayload == false) @@ -130,25 +130,34 @@ class TalkModeConfigParsingTest { fun readsConfiguredSilenceTimeoutMs() { val talk = buildJsonObject { put("silenceTimeoutMs", 1500) } - assertEquals(1500L, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk)) } @Test fun defaultsSilenceTimeoutMsWhenMissing() { - assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(null)) + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null), + ) } @Test fun defaultsSilenceTimeoutMsWhenInvalid() { val talk = buildJsonObject { put("silenceTimeoutMs", 0) } - assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk), + ) } @Test fun defaultsSilenceTimeoutMsWhenString() { val talk = buildJsonObject { put("silenceTimeoutMs", "1500") } - assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk)) + assertEquals( + TalkDefaults.defaultSilenceTimeoutMs, + TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk), + ) } } From 371c53b282727c38446fe6e9499f8964130362de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:28:06 +0000 Subject: [PATCH 116/311] test: expand talk config contract fixtures --- .../app/voice/TalkModeConfigContractTest.kt | 24 +++++++ .../TalkConfigContractTests.swift | 20 ++++++ .../protocol/talk-config.contract.test.ts | 20 ++++++ test-fixtures/talk-config-contract.json | 65 +++++++++++++++++-- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt index d2df33793e29e..ca9be8b12805c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeConfigContractTest.kt @@ -14,6 +14,7 @@ import org.junit.Test @Serializable private data class TalkConfigContractFixture( @SerialName("selectionCases") val selectionCases: List, + @SerialName("timeoutCases") val timeoutCases: List, ) { @Serializable data class SelectionCase( @@ -29,6 +30,15 @@ private data class TalkConfigContractFixture( val provider: String, val normalizedPayload: Boolean, val voiceId: String? = null, + val apiKey: String? = null, + ) + + @Serializable + data class TimeoutCase( + val id: String, + val fallback: Long, + val expectedTimeoutMs: Long, + val talk: JsonObject, ) } @@ -52,10 +62,24 @@ class TalkModeConfigContractTest { expected.voiceId, (selection?.config?.get("voiceId") as? JsonPrimitive)?.content, ) + assertEquals( + fixture.id, + expected.apiKey, + (selection?.config?.get("apiKey") as? JsonPrimitive)?.content, + ) assertEquals(fixture.id, true, fixture.payloadValid) } } + @Test + fun timeoutFixtures() { + for (fixture in loadFixtures().timeoutCases) { + val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk) + assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout) + assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback) + } + } + private fun loadFixtures(): TalkConfigContractFixture { val fixturePath = findFixtureFile() return json.decodeFromString(File(fixturePath).readText()) diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift index 3ca2031e03e3f..1903d9178601e 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkConfigContractTests.swift @@ -4,6 +4,7 @@ import Testing private struct TalkConfigContractFixture: Decodable { let selectionCases: [SelectionCase] + let timeoutCases: [TimeoutCase] struct SelectionCase: Decodable { let id: String @@ -17,6 +18,14 @@ private struct TalkConfigContractFixture: Decodable { let provider: String let normalizedPayload: Bool let voiceId: String? + let apiKey: String? + } + + struct TimeoutCase: Decodable { + let id: String + let fallback: Int + let expectedTimeoutMs: Int + let talk: [String: AnyCodable] } } @@ -51,10 +60,21 @@ struct TalkConfigContractTests { #expect(selection?.provider == expected.provider) #expect(selection?.normalizedPayload == expected.normalizedPayload) #expect(selection?.config["voiceId"]?.stringValue == expected.voiceId) + #expect(selection?.config["apiKey"]?.stringValue == expected.apiKey) } else { #expect(selection == nil) } #expect(fixture.payloadValid == (selection != nil)) } } + + @Test func timeoutFixtures() throws { + for fixture in try TalkConfigContractFixtureLoader.load().timeoutCases { + #expect( + TalkConfigParsing.resolvedSilenceTimeoutMs( + fixture.talk, + fallback: fixture.fallback) == fixture.expectedTimeoutMs, + "\(fixture.id)") + } + } } diff --git a/src/gateway/protocol/talk-config.contract.test.ts b/src/gateway/protocol/talk-config.contract.test.ts index 7d77dbc37ca7c..d6bc1a7444010 100644 --- a/src/gateway/protocol/talk-config.contract.test.ts +++ b/src/gateway/protocol/talk-config.contract.test.ts @@ -1,11 +1,13 @@ import fs from "node:fs"; import { describe, expect, it } from "vitest"; +import { buildTalkConfigResponse } from "../../config/talk.js"; import { validateTalkConfigResult } from "./index.js"; type ExpectedSelection = { provider: string; normalizedPayload: boolean; voiceId?: string; + apiKey?: string; }; type SelectionContractCase = { @@ -16,8 +18,16 @@ type SelectionContractCase = { talk: Record; }; +type TimeoutContractCase = { + id: string; + fallback: number; + expectedTimeoutMs: number; + talk: Record; +}; + type TalkConfigContractFixture = { selectionCases: SelectionContractCase[]; + timeoutCases: TimeoutContractCase[]; }; const fixturePath = new URL("../../../test-fixtures/talk-config-contract.json", import.meta.url); @@ -42,9 +52,11 @@ describe("talk.config contract fixtures", () => { provider?: string; config?: { voiceId?: string; + apiKey?: string; }; }; voiceId?: string; + apiKey?: string; }; expect(talk.resolved?.provider ?? fixture.defaultProvider).toBe( fixture.expectedSelection.provider, @@ -52,6 +64,14 @@ describe("talk.config contract fixtures", () => { expect(talk.resolved?.config?.voiceId ?? talk.voiceId).toBe( fixture.expectedSelection.voiceId, ); + expect(talk.resolved?.config?.apiKey ?? talk.apiKey).toBe(fixture.expectedSelection.apiKey); + }); + } + + for (const fixture of fixtures.timeoutCases) { + it(`timeout:${fixture.id}`, () => { + const payload = buildTalkConfigResponse(fixture.talk); + expect(payload?.silenceTimeoutMs ?? fixture.fallback).toBe(fixture.expectedTimeoutMs); }); } }); diff --git a/test-fixtures/talk-config-contract.json b/test-fixtures/talk-config-contract.json index ed3a7a4605514..c94952acaf9a7 100644 --- a/test-fixtures/talk-config-contract.json +++ b/test-fixtures/talk-config-contract.json @@ -7,22 +7,26 @@ "expectedSelection": { "provider": "elevenlabs", "normalizedPayload": true, - "voiceId": "voice-resolved" + "voiceId": "voice-resolved", + "apiKey": "resolved-key" }, "talk": { "resolved": { "provider": "elevenlabs", "config": { - "voiceId": "voice-resolved" + "voiceId": "voice-resolved", + "apiKey": "resolved-key" } }, "provider": "elevenlabs", "providers": { "elevenlabs": { - "voiceId": "voice-normalized" + "voiceId": "voice-normalized", + "apiKey": "normalized-key" } }, - "voiceId": "voice-legacy" + "voiceId": "voice-legacy", + "apiKey": "legacy-key" } }, { @@ -77,12 +81,63 @@ "expectedSelection": { "provider": "elevenlabs", "normalizedPayload": false, - "voiceId": "voice-legacy" + "voiceId": "voice-legacy", + "apiKey": "legacy-key" }, "talk": { "voiceId": "voice-legacy", "apiKey": "xxxxx" } } + ], + "timeoutCases": [ + { + "id": "integer_timeout_kept", + "fallback": 700, + "expectedTimeoutMs": 1500, + "talk": { + "silenceTimeoutMs": 1500 + } + }, + { + "id": "integer_like_double_timeout_kept", + "fallback": 700, + "expectedTimeoutMs": 1500, + "talk": { + "silenceTimeoutMs": 1500.0 + } + }, + { + "id": "zero_timeout_falls_back", + "fallback": 700, + "expectedTimeoutMs": 700, + "talk": { + "silenceTimeoutMs": 0 + } + }, + { + "id": "boolean_timeout_falls_back", + "fallback": 700, + "expectedTimeoutMs": 700, + "talk": { + "silenceTimeoutMs": true + } + }, + { + "id": "string_timeout_falls_back", + "fallback": 700, + "expectedTimeoutMs": 700, + "talk": { + "silenceTimeoutMs": "1500" + } + }, + { + "id": "fractional_timeout_falls_back", + "fallback": 700, + "expectedTimeoutMs": 700, + "talk": { + "silenceTimeoutMs": 1500.5 + } + } ] } From fa580e33c12f9786c9bbd2ff1bafe5b964616596 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:30:58 +0000 Subject: [PATCH 117/311] refactor: split android talk voice resolution --- .../ai/openclaw/app/voice/TalkModeManager.kt | 105 ++++------------ .../app/voice/TalkModeVoiceResolver.kt | 118 ++++++++++++++++++ .../app/voice/TalkModeVoiceResolverTest.kt | 92 ++++++++++++++ 3 files changed, 236 insertions(+), 79 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeVoiceResolverTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 07bd8a346f06d..70b6113fc35d5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -813,7 +813,7 @@ class TalkModeManager( _lastAssistantText.value = cleaned val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() } - val resolvedVoice = resolveVoiceAlias(requestedVoice) + val resolvedVoice = TalkModeVoiceResolver.resolveVoiceAlias(requestedVoice, voiceAliases) if (requestedVoice != null && resolvedVoice == null) { Log.w(tag, "unknown voice alias: $requestedVoice") } @@ -836,12 +836,35 @@ class TalkModeManager( apiKey?.trim()?.takeIf { it.isNotEmpty() } ?: System.getenv("ELEVENLABS_API_KEY")?.trim() val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId - val voiceId = + val resolvedPlaybackVoice = if (!apiKey.isNullOrEmpty()) { - resolveVoiceId(preferredVoice, apiKey) + try { + TalkModeVoiceResolver.resolveVoiceId( + preferred = preferredVoice, + fallbackVoiceId = fallbackVoiceId, + defaultVoiceId = defaultVoiceId, + currentVoiceId = currentVoiceId, + voiceOverrideActive = voiceOverrideActive, + listVoices = { TalkModeVoiceResolver.listVoices(apiKey, json) }, + ) + } catch (err: Throwable) { + Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}") + null + } } else { null } + resolvedPlaybackVoice?.let { resolved -> + fallbackVoiceId = resolved.fallbackVoiceId + defaultVoiceId = resolved.defaultVoiceId + currentVoiceId = resolved.currentVoiceId + resolved.selectedVoiceName?.let { name -> + resolved.voiceId?.let { voiceId -> + Log.d(tag, "default voice selected $name ($voiceId)") + } + } + } + val voiceId = resolvedPlaybackVoice?.voiceId _statusText.value = "Speaking…" _isSpeaking.value = true @@ -1703,82 +1726,6 @@ class TalkModeManager( } } - private fun resolveVoiceAlias(value: String?): String? { - val trimmed = value?.trim().orEmpty() - if (trimmed.isEmpty()) return null - val normalized = normalizeAliasKey(trimmed) - voiceAliases[normalized]?.let { return it } - if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed - return if (isLikelyVoiceId(trimmed)) trimmed else null - } - - private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? { - val trimmed = preferred?.trim().orEmpty() - if (trimmed.isNotEmpty()) { - val resolved = resolveVoiceAlias(trimmed) - // If it resolves as an alias, use the alias target. - // Otherwise treat it as a direct voice ID (e.g. "21m00Tcm4TlvDq8ikWAM"). - return resolved ?: trimmed - } - fallbackVoiceId?.let { return it } - - return try { - val voices = listVoices(apiKey) - val first = voices.firstOrNull() ?: return null - fallbackVoiceId = first.voiceId - if (defaultVoiceId.isNullOrBlank()) { - defaultVoiceId = first.voiceId - } - if (!voiceOverrideActive) { - currentVoiceId = first.voiceId - } - val name = first.name ?: "unknown" - Log.d(tag, "default voice selected $name (${first.voiceId})") - first.voiceId - } catch (err: Throwable) { - Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}") - null - } - } - - private suspend fun listVoices(apiKey: String): List { - return withContext(Dispatchers.IO) { - val url = URL("https://api.elevenlabs.io/v1/voices") - val conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 15_000 - conn.readTimeout = 15_000 - conn.setRequestProperty("xi-api-key", apiKey) - - val code = conn.responseCode - val stream = if (code >= 400) conn.errorStream else conn.inputStream - val data = stream.readBytes() - if (code >= 400) { - val message = data.toString(Charsets.UTF_8) - throw IllegalStateException("ElevenLabs voices failed: $code $message") - } - - val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() - val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) - voices.mapNotNull { entry -> - val obj = entry.asObjectOrNull() ?: return@mapNotNull null - val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null - val name = obj["name"].asStringOrNull() - ElevenLabsVoice(voiceId, name) - } - } - } - - private fun isLikelyVoiceId(value: String): Boolean { - if (value.length < 10) return false - return value.all { it.isLetterOrDigit() || it == '-' || it == '_' } - } - - private fun normalizeAliasKey(value: String): String = - value.trim().lowercase() - - private data class ElevenLabsVoice(val voiceId: String, val name: String?) - private val listener = object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt new file mode 100644 index 0000000000000..eff5201762439 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt @@ -0,0 +1,118 @@ +package ai.openclaw.app.voice + +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal data class ElevenLabsVoice(val voiceId: String, val name: String?) + +internal data class TalkModeResolvedVoice( + val voiceId: String?, + val fallbackVoiceId: String?, + val defaultVoiceId: String?, + val currentVoiceId: String?, + val selectedVoiceName: String? = null, +) + +internal object TalkModeVoiceResolver { + fun resolveVoiceAlias(value: String?, voiceAliases: Map): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val normalized = normalizeAliasKey(trimmed) + voiceAliases[normalized]?.let { return it } + if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed + return if (isLikelyVoiceId(trimmed)) trimmed else null + } + + suspend fun resolveVoiceId( + preferred: String?, + fallbackVoiceId: String?, + defaultVoiceId: String?, + currentVoiceId: String?, + voiceOverrideActive: Boolean, + listVoices: suspend () -> List, + ): TalkModeResolvedVoice { + val trimmed = preferred?.trim().orEmpty() + if (trimmed.isNotEmpty()) { + return TalkModeResolvedVoice( + voiceId = trimmed, + fallbackVoiceId = fallbackVoiceId, + defaultVoiceId = defaultVoiceId, + currentVoiceId = currentVoiceId, + ) + } + if (!fallbackVoiceId.isNullOrBlank()) { + return TalkModeResolvedVoice( + voiceId = fallbackVoiceId, + fallbackVoiceId = fallbackVoiceId, + defaultVoiceId = defaultVoiceId, + currentVoiceId = currentVoiceId, + ) + } + + val first = listVoices().firstOrNull() + if (first == null) { + return TalkModeResolvedVoice( + voiceId = null, + fallbackVoiceId = fallbackVoiceId, + defaultVoiceId = defaultVoiceId, + currentVoiceId = currentVoiceId, + ) + } + + return TalkModeResolvedVoice( + voiceId = first.voiceId, + fallbackVoiceId = first.voiceId, + defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId, + currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId, + selectedVoiceName = first.name, + ) + } + + suspend fun listVoices(apiKey: String, json: Json): List { + return withContext(Dispatchers.IO) { + val url = URL("https://api.elevenlabs.io/v1/voices") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.setRequestProperty("xi-api-key", apiKey) + + val code = conn.responseCode + val stream = if (code >= 400) conn.errorStream else conn.inputStream + val data = stream.readBytes() + if (code >= 400) { + val message = data.toString(Charsets.UTF_8) + throw IllegalStateException("ElevenLabs voices failed: $code $message") + } + + val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull() + val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList()) + voices.mapNotNull { entry -> + val obj = entry.asObjectOrNull() ?: return@mapNotNull null + val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null + val name = obj["name"].asStringOrNull() + ElevenLabsVoice(voiceId, name) + } + } + } + + private fun isLikelyVoiceId(value: String): Boolean { + if (value.length < 10) return false + return value.all { it.isLetterOrDigit() || it == '-' || it == '_' } + } + + private fun normalizeAliasKey(value: String): String = + value.trim().lowercase() +} + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject + +private fun JsonElement?.asStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeVoiceResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeVoiceResolverTest.kt new file mode 100644 index 0000000000000..5cd46895d4286 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeVoiceResolverTest.kt @@ -0,0 +1,92 @@ +package ai.openclaw.app.voice + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TalkModeVoiceResolverTest { + @Test + fun resolvesVoiceAliasCaseInsensitively() { + val resolved = + TalkModeVoiceResolver.resolveVoiceAlias( + " Clawd ", + mapOf("clawd" to "voice-123"), + ) + + assertEquals("voice-123", resolved) + } + + @Test + fun acceptsDirectVoiceIds() { + val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap()) + + assertEquals("21m00Tcm4TlvDq8ikWAM", resolved) + } + + @Test + fun rejectsUnknownAliases() { + val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap()) + + assertNull(resolved) + } + + @Test + fun reusesCachedFallbackVoiceBeforeFetchingCatalog() = + runBlocking { + var fetchCount = 0 + + val resolved = + TalkModeVoiceResolver.resolveVoiceId( + preferred = null, + fallbackVoiceId = "cached-voice", + defaultVoiceId = null, + currentVoiceId = null, + voiceOverrideActive = false, + listVoices = { + fetchCount += 1 + emptyList() + }, + ) + + assertEquals("cached-voice", resolved.voiceId) + assertEquals(0, fetchCount) + } + + @Test + fun seedsDefaultVoiceFromCatalogWhenNeeded() = + runBlocking { + val resolved = + TalkModeVoiceResolver.resolveVoiceId( + preferred = null, + fallbackVoiceId = null, + defaultVoiceId = null, + currentVoiceId = null, + voiceOverrideActive = false, + listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) }, + ) + + assertEquals("voice-1", resolved.voiceId) + assertEquals("voice-1", resolved.fallbackVoiceId) + assertEquals("voice-1", resolved.defaultVoiceId) + assertEquals("voice-1", resolved.currentVoiceId) + assertEquals("First", resolved.selectedVoiceName) + } + + @Test + fun preservesCurrentVoiceWhenOverrideIsActive() = + runBlocking { + val resolved = + TalkModeVoiceResolver.resolveVoiceId( + preferred = null, + fallbackVoiceId = null, + defaultVoiceId = null, + currentVoiceId = null, + voiceOverrideActive = true, + listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) }, + ) + + assertEquals("voice-1", resolved.voiceId) + assertNull(resolved.currentVoiceId) + } +} From a007bed37551cf2b6f4cef08ad238c08ade7d9fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:37:02 +0000 Subject: [PATCH 118/311] test: isolate plugin loader from mocked module cache --- src/plugins/loader.test.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 167889b61b4cf..0af3cc281f07c 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3,11 +3,31 @@ import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; -import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; -import { createHookRunner } from "./hooks.js"; +async function importFreshPluginTestModules() { + vi.resetModules(); + vi.unmock("./hook-runner-global.js"); + vi.unmock("./hooks.js"); + vi.unmock("./loader.js"); + vi.unmock("jiti"); + const [loader, hookRunnerGlobal, hooks] = await Promise.all([ + import("./loader.js"), + import("./hook-runner-global.js"), + import("./hooks.js"), + ]); + return { + ...loader, + ...hookRunnerGlobal, + ...hooks, + }; +} -vi.unmock("jiti"); -const { __testing, loadOpenClawPlugins } = await import("./loader.js"); +const { + __testing, + createHookRunner, + getGlobalHookRunner, + loadOpenClawPlugins, + resetGlobalHookRunner, +} = await importFreshPluginTestModules(); type TempPlugin = { dir: string; file: string; id: string }; From c70151e873aaa4b6549968b80e092bd1872be0af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:45:05 +0000 Subject: [PATCH 119/311] test: isolate legacy plugin-sdk root import check --- src/plugins/loader.test.ts | 43 +++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0af3cc281f07c..58f44bce9628f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,10 +1,15 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; async function importFreshPluginTestModules() { vi.resetModules(); + vi.unmock("node:fs"); + vi.unmock("node:fs/promises"); + vi.unmock("node:module"); vi.unmock("./hook-runner-global.js"); vi.unmock("./hooks.js"); vi.unmock("./loader.js"); @@ -1337,7 +1342,7 @@ describe("loadOpenClawPlugins", () => { expect(record?.status).toBe("loaded"); }); - it("supports legacy plugins importing monolithic plugin-sdk root", () => { + it("supports legacy plugins importing monolithic plugin-sdk root", async () => { useNoBundledPlugins(); const plugin = writePlugin({ id: "legacy-root-import", @@ -1349,15 +1354,37 @@ describe("loadOpenClawPlugins", () => { };`, }); - const registry = loadRegistryFromSinglePlugin({ - plugin, - pluginConfig: { - allow: ["legacy-root-import"], + const loaderModuleUrl = pathToFileURL( + path.join(process.cwd(), "src", "plugins", "loader.ts"), + ).href; + const script = ` + import { loadOpenClawPlugins } from ${JSON.stringify(loaderModuleUrl)}; + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: ${JSON.stringify(plugin.dir)}, + config: { + plugins: { + load: { paths: [${JSON.stringify(plugin.file)}] }, + allow: ["legacy-root-import"], + }, + }, + }); + const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); + if (!record || record.status !== "loaded") { + console.error(record?.error ?? "legacy-root-import missing"); + process.exit(1); + } + `; + + execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", }, + encoding: "utf-8", + stdio: "pipe", }); - - const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); - expect({ status: record?.status, error: record?.error }).toMatchObject({ status: "loaded" }); }); it("prefers dist plugin-sdk alias when loader runs from dist", () => { From dd7470730d211c9daaaa043edeeea5c7a5ea7079 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:05:53 +0000 Subject: [PATCH 120/311] test: isolate git commit resolution fallbacks --- src/infra/git-commit.test.ts | 231 ++++++++++++++--------------------- src/infra/git-commit.ts | 25 +++- 2 files changed, 111 insertions(+), 145 deletions(-) diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 5f4e7549ca718..61804e6f95f58 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -41,12 +41,14 @@ async function makeFakeGitRepo( describe("git commit resolution", () => { const originalCwd = process.cwd(); - afterEach(() => { + afterEach(async () => { process.chdir(originalCwd); vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); vi.resetModules(); + const { __testing } = await import("./git-commit.js"); + __testing.clearCachedGitCommits(); }); it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { @@ -85,85 +87,64 @@ describe("git commit resolution", () => { encoding: "utf-8", }).trim(); - vi.doMock("node:module", () => ({ - createRequire: () => { - return (specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }; - }, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; - expect(resolveCommitHash({ moduleUrl: entryModuleUrl, env: {} })).toBe(repoHead); - - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + moduleUrl: entryModuleUrl, + env: {}, + readers: { + readBuildInfoCommit: () => "deadbee", + }, + }), + ).toBe(repoHead); }); it("caches build-info fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-build-info-cache"); - const moduleRequire = vi.fn((specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }); - - vi.doMock("node:module", () => ({ - createRequire: () => moduleRequire, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readBuildInfoCommit = vi.fn(() => "deadbee"); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); - const firstCallRequires = moduleRequire.mock.calls.length; + expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( + "deadbee", + ); + const firstCallRequires = readBuildInfoCommit.mock.calls.length; expect(firstCallRequires).toBeGreaterThan(0); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); - expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); - - vi.doUnmock("node:module"); + expect(resolveCommitHash({ cwd: temp, env: {}, readers: { readBuildInfoCommit } })).toBe( + "deadbee", + ); + expect(readBuildInfoCommit.mock.calls.length).toBe(firstCallRequires); }); it("caches package.json fallback results per resolved search directory", async () => { const temp = await makeTempDir("git-commit-package-json-cache"); - const moduleRequire = vi.fn((specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - } - if (specifier === "../../package.json") { - return { gitHead: "badc0ffee0ddf00d" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }); - - vi.doMock("node:module", () => ({ - createRequire: () => moduleRequire, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readPackageJsonCommit = vi.fn(() => "badc0ff"); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); - const firstCallRequires = moduleRequire.mock.calls.length; + expect( + resolveCommitHash({ + cwd: temp, + env: {}, + readers: { + readBuildInfoCommit: () => null, + readPackageJsonCommit, + }, + }), + ).toBe("badc0ff"); + const firstCallRequires = readPackageJsonCommit.mock.calls.length; expect(firstCallRequires).toBeGreaterThan(0); - expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); - expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); - - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + cwd: temp, + env: {}, + readers: { + readBuildInfoCommit: () => null, + readPackageJsonCommit, + }, + }), + ).toBe("badc0ff"); + expect(readPackageJsonCommit.mock.calls.length).toBe(firstCallRequires); }); it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => { @@ -204,28 +185,19 @@ describe("git commit resolution", () => { ); const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; - vi.doMock("node:module", () => ({ - createRequire: () => { - return (specifier: string) => { - if (specifier === "../build-info.json" || specifier === "./build-info.json") { - return { commit: "feedfacefeedfacefeedfacefeedfacefeedface" }; - } - if (specifier === "../../package.json") { - return { name: "openclaw", version: "2026.3.8", gitHead: "badc0ffee0ddf00d" }; - } - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }; - }, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); - expect(resolveCommitHash({ moduleUrl, cwd: packageRoot, env: {} })).toBe("feedfac"); - - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + moduleUrl, + cwd: packageRoot, + env: {}, + readers: { + readBuildInfoCommit: () => "feedfac", + readPackageJsonCommit: () => "badc0ff", + }, + }), + ).toBe("feedfac"); }); it("caches git lookups per resolved search directory", async () => { @@ -253,27 +225,14 @@ describe("git commit resolution", () => { head: "not-a-commit\n", }); - const actualFs = await vi.importActual("node:fs"); - const readFileSyncSpy = vi.fn(actualFs.readFileSync); - vi.doMock("node:fs", () => ({ - ...actualFs, - default: { - ...actualFs, - readFileSync: readFileSyncSpy, - }, - readFileSync: readFileSyncSpy, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readGitCommit = vi.fn(() => null); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - const firstCallReads = readFileSyncSpy.mock.calls.length; + expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); + const firstCallReads = readGitCommit.mock.calls.length; expect(firstCallReads).toBeGreaterThan(0); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - expect(readFileSyncSpy.mock.calls.length).toBe(firstCallReads); - - vi.doUnmock("node:fs"); + expect(resolveCommitHash({ cwd: repoRoot, env: {}, readers: { readGitCommit } })).toBeNull(); + expect(readGitCommit.mock.calls.length).toBe(firstCallReads); }); it("caches caught null fallback results per resolved search directory", async () => { @@ -282,49 +241,39 @@ describe("git commit resolution", () => { await makeFakeGitRepo(repoRoot, { head: "0123456789abcdef0123456789abcdef01234567\n", }); - const headPath = path.join(repoRoot, ".git", "HEAD"); - - const actualFs = await vi.importActual("node:fs"); - const readFileSyncSpy = vi.fn((filePath: string, ...args: unknown[]) => { - if (path.resolve(filePath) === path.resolve(headPath)) { - const error = Object.assign(new Error(`EACCES: permission denied, open '${filePath}'`), { - code: "EACCES", - }); - throw error; - } - return Reflect.apply(actualFs.readFileSync, actualFs, [filePath, ...args]); - }); - vi.doMock("node:fs", () => ({ - ...actualFs, - default: { - ...actualFs, - readFileSync: readFileSyncSpy, - }, - readFileSync: readFileSyncSpy, - })); - vi.doMock("node:module", () => ({ - createRequire: () => { - return (specifier: string) => { - throw Object.assign(new Error(`Cannot find module ${specifier}`), { - code: "MODULE_NOT_FOUND", - }); - }; - }, - })); - vi.resetModules(); - const { resolveCommitHash } = await import("./git-commit.js"); + const readGitCommit = vi.fn(() => { + const error = Object.assign(new Error(`EACCES: permission denied`), { + code: "EACCES", + }); + throw error; + }); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - const headReadCount = () => - readFileSyncSpy.mock.calls.filter(([filePath]) => path.resolve(filePath) === headPath).length; - const firstCallReads = headReadCount(); + expect( + resolveCommitHash({ + cwd: repoRoot, + env: {}, + readers: { + readGitCommit, + readBuildInfoCommit: () => null, + readPackageJsonCommit: () => null, + }, + }), + ).toBeNull(); + const firstCallReads = readGitCommit.mock.calls.length; expect(firstCallReads).toBe(2); - expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); - expect(headReadCount()).toBe(firstCallReads); - - vi.doUnmock("node:fs"); - vi.doUnmock("node:module"); + expect( + resolveCommitHash({ + cwd: repoRoot, + env: {}, + readers: { + readGitCommit, + readBuildInfoCommit: () => null, + readPackageJsonCommit: () => null, + }, + }), + ).toBeNull(); + expect(readGitCommit.mock.calls.length).toBe(firstCallReads); }); it("formats env-provided commit strings consistently", async () => { diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index 74e22156e1b71..e413fc9fa9d57 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -22,6 +22,12 @@ const formatCommit = (value?: string | null) => { const cachedGitCommitBySearchDir = new Map(); +export type CommitMetadataReaders = { + readGitCommit?: (searchDir: string, packageRoot: string | null) => string | null | undefined; + readBuildInfoCommit?: () => string | null; + readPackageJsonCommit?: () => string | null; +}; + function isMissingPathError(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -61,6 +67,10 @@ const cacheGitCommit = (searchDir: string, commit: string | null) => { return commit; }; +const clearCachedGitCommits = () => { + cachedGitCommitBySearchDir.clear(); +}; + const resolveGitLookupDepth = (searchDir: string, packageRoot: string | null) => { if (!packageRoot) { return undefined; @@ -176,9 +186,12 @@ export const resolveCommitHash = ( cwd?: string; env?: NodeJS.ProcessEnv; moduleUrl?: string; + readers?: CommitMetadataReaders; } = {}, ) => { const env = options.env ?? process.env; + const readers = options.readers ?? {}; + const readGitCommit = readers.readGitCommit ?? readCommitFromGit; const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); const normalized = formatCommit(envCommit); if (normalized) { @@ -193,24 +206,28 @@ export const resolveCommitHash = ( moduleUrl: options.moduleUrl, }); try { - const gitCommit = readCommitFromGit(searchDir, packageRoot); + const gitCommit = readGitCommit(searchDir, packageRoot); if (gitCommit !== undefined) { return cacheGitCommit(searchDir, gitCommit); } } catch { // Fall through to baked metadata for packaged installs that are not in a live checkout. } - const buildInfoCommit = readCommitFromBuildInfo(); + const buildInfoCommit = readers.readBuildInfoCommit?.() ?? readCommitFromBuildInfo(); if (buildInfoCommit) { return cacheGitCommit(searchDir, buildInfoCommit); } - const pkgCommit = readCommitFromPackageJson(); + const pkgCommit = readers.readPackageJsonCommit?.() ?? readCommitFromPackageJson(); if (pkgCommit) { return cacheGitCommit(searchDir, pkgCommit); } try { - return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null); + return cacheGitCommit(searchDir, readGitCommit(searchDir, packageRoot) ?? null); } catch { return cacheGitCommit(searchDir, null); } }; + +export const __testing = { + clearCachedGitCommits, +}; From 25d0aa7296031f7f60d3024fec0bc6b725a3330c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:08:33 +0000 Subject: [PATCH 121/311] refactor: simplify plugin sdk compatibility aliases --- src/plugin-sdk/root-alias.cjs | 106 +++++------------------- src/plugin-sdk/root-alias.test.ts | 2 +- src/plugins/loader.test.ts | 7 ++ src/plugins/loader.ts | 129 +++++++++--------------------- 4 files changed, 64 insertions(+), 180 deletions(-) diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index aa2127bdc9a1d..9e3c2e5d1a874 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -108,92 +108,26 @@ const fastExports = { resolveControlCommandGate, }; -const rootProxy = new Proxy(fastExports, { - get(target, prop, receiver) { - if (prop === "__esModule") { - return true; - } - if (prop === "default") { - return rootProxy; - } - if (Reflect.has(target, prop)) { - return Reflect.get(target, prop, receiver); - } - return loadMonolithicSdk()[prop]; - }, - has(target, prop) { - if (prop === "__esModule" || prop === "default") { - return true; - } - if (Reflect.has(target, prop)) { - return true; - } - const monolithic = tryLoadMonolithicSdk(); - return monolithic ? prop in monolithic : false; - }, - ownKeys(target) { - const keys = new Set([...Reflect.ownKeys(target), "default", "__esModule"]); - // Keep Object.keys/property reflection fast and deterministic. - // Only expose monolithic keys if it was already loaded by direct access. - if (monolithicSdk) { - for (const key of Reflect.ownKeys(monolithicSdk)) { - keys.add(key); +const monolithic = tryLoadMonolithicSdk(); +const rootExports = + monolithic && typeof monolithic === "object" + ? { + ...monolithic, + ...fastExports, } - } - return [...keys]; - }, - getOwnPropertyDescriptor(target, prop) { - if (prop === "__esModule") { - return { - configurable: true, - enumerable: false, - writable: false, - value: true, - }; - } - if (prop === "default") { - return { - configurable: true, - enumerable: false, - writable: false, - value: rootProxy, - }; - } - const own = Object.getOwnPropertyDescriptor(target, prop); - if (own) { - return own; - } - const monolithic = tryLoadMonolithicSdk(); - if (!monolithic) { - return undefined; - } - const descriptor = Object.getOwnPropertyDescriptor(monolithic, prop); - if (!descriptor) { - return undefined; - } - if (descriptor.get || descriptor.set) { - return { - configurable: true, - enumerable: descriptor.enumerable ?? true, - get: descriptor.get - ? function getLegacyValue() { - return descriptor.get.call(monolithic); - } - : undefined, - set: descriptor.set - ? function setLegacyValue(value) { - return descriptor.set.call(monolithic, value); - } - : undefined, - }; - } - return { - configurable: true, - enumerable: descriptor.enumerable ?? true, - value: descriptor.value, - writable: descriptor.writable, - }; - }, + : { ...fastExports }; + +Object.defineProperty(rootExports, "__esModule", { + configurable: true, + enumerable: false, + writable: false, + value: true, +}); +Object.defineProperty(rootExports, "default", { + configurable: true, + enumerable: false, + writable: false, + value: rootExports, }); -module.exports = rootProxy; +module.exports = rootExports; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 6cffdd3c95981..8757d3ce34cfb 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -27,7 +27,7 @@ describe("plugin-sdk root alias", () => { expect(parsed.success).toBe(false); }); - it("loads legacy root exports lazily through the proxy", { timeout: 240_000 }, () => { + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); expect(typeof rootSdk.default).toBe("object"); expect(rootSdk.default).toBe(rootSdk); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 58f44bce9628f..cff49aa8a1946 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1439,6 +1439,13 @@ describe("loadOpenClawPlugins", () => { expect(candidates.indexOf(srcFile)).toBeLessThan(candidates.indexOf(distFile)); }); + it("derives plugin-sdk subpaths from package exports", () => { + const subpaths = __testing.listPluginSdkExportedSubpaths(); + expect(subpaths).toContain("compat"); + expect(subpaths).toContain("telegram"); + expect(subpaths).not.toContain("root-alias"); + }); + it("falls back to src plugin-sdk alias when dist is missing in production", () => { const { root, srcFile, distFile } = createPluginSdkAliasFixture(); fs.rmSync(distFile); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 073e735a7074b..41a2f0fa3f81b 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -5,6 +5,7 @@ import { createJiti } from "jiti"; import type { OpenClawConfig } from "../config/config.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { clearPluginCommands } from "./commands.js"; @@ -111,105 +112,46 @@ const resolvePluginSdkAliasFile = (params: { const resolvePluginSdkAlias = (): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); -const pluginSdkScopedAliasEntries = [ - { subpath: "core", srcFile: "core.ts", distFile: "core.js" }, - { subpath: "compat", srcFile: "compat.ts", distFile: "compat.js" }, - { subpath: "telegram", srcFile: "telegram.ts", distFile: "telegram.js" }, - { subpath: "discord", srcFile: "discord.ts", distFile: "discord.js" }, - { subpath: "slack", srcFile: "slack.ts", distFile: "slack.js" }, - { subpath: "signal", srcFile: "signal.ts", distFile: "signal.js" }, - { subpath: "imessage", srcFile: "imessage.ts", distFile: "imessage.js" }, - { subpath: "whatsapp", srcFile: "whatsapp.ts", distFile: "whatsapp.js" }, - { subpath: "line", srcFile: "line.ts", distFile: "line.js" }, - { subpath: "msteams", srcFile: "msteams.ts", distFile: "msteams.js" }, - { subpath: "acpx", srcFile: "acpx.ts", distFile: "acpx.js" }, - { subpath: "bluebubbles", srcFile: "bluebubbles.ts", distFile: "bluebubbles.js" }, - { - subpath: "copilot-proxy", - srcFile: "copilot-proxy.ts", - distFile: "copilot-proxy.js", - }, - { subpath: "device-pair", srcFile: "device-pair.ts", distFile: "device-pair.js" }, - { - subpath: "diagnostics-otel", - srcFile: "diagnostics-otel.ts", - distFile: "diagnostics-otel.js", - }, - { subpath: "diffs", srcFile: "diffs.ts", distFile: "diffs.js" }, - { subpath: "feishu", srcFile: "feishu.ts", distFile: "feishu.js" }, - { - subpath: "google-gemini-cli-auth", - srcFile: "google-gemini-cli-auth.ts", - distFile: "google-gemini-cli-auth.js", - }, - { subpath: "googlechat", srcFile: "googlechat.ts", distFile: "googlechat.js" }, - { subpath: "irc", srcFile: "irc.ts", distFile: "irc.js" }, - { subpath: "llm-task", srcFile: "llm-task.ts", distFile: "llm-task.js" }, - { subpath: "lobster", srcFile: "lobster.ts", distFile: "lobster.js" }, - { subpath: "matrix", srcFile: "matrix.ts", distFile: "matrix.js" }, - { subpath: "mattermost", srcFile: "mattermost.ts", distFile: "mattermost.js" }, - { subpath: "memory-core", srcFile: "memory-core.ts", distFile: "memory-core.js" }, - { - subpath: "memory-lancedb", - srcFile: "memory-lancedb.ts", - distFile: "memory-lancedb.js", - }, - { - subpath: "minimax-portal-auth", - srcFile: "minimax-portal-auth.ts", - distFile: "minimax-portal-auth.js", - }, - { - subpath: "nextcloud-talk", - srcFile: "nextcloud-talk.ts", - distFile: "nextcloud-talk.js", - }, - { subpath: "nostr", srcFile: "nostr.ts", distFile: "nostr.js" }, - { subpath: "open-prose", srcFile: "open-prose.ts", distFile: "open-prose.js" }, - { - subpath: "phone-control", - srcFile: "phone-control.ts", - distFile: "phone-control.js", - }, - { - subpath: "qwen-portal-auth", - srcFile: "qwen-portal-auth.ts", - distFile: "qwen-portal-auth.js", - }, - { - subpath: "synology-chat", - srcFile: "synology-chat.ts", - distFile: "synology-chat.js", - }, - { subpath: "talk-voice", srcFile: "talk-voice.ts", distFile: "talk-voice.js" }, - { subpath: "test-utils", srcFile: "test-utils.ts", distFile: "test-utils.js" }, - { - subpath: "thread-ownership", - srcFile: "thread-ownership.ts", - distFile: "thread-ownership.js", - }, - { subpath: "tlon", srcFile: "tlon.ts", distFile: "tlon.js" }, - { subpath: "twitch", srcFile: "twitch.ts", distFile: "twitch.js" }, - { subpath: "voice-call", srcFile: "voice-call.ts", distFile: "voice-call.js" }, - { subpath: "zalo", srcFile: "zalo.ts", distFile: "zalo.js" }, - { subpath: "zalouser", srcFile: "zalouser.ts", distFile: "zalouser.js" }, - { subpath: "account-id", srcFile: "account-id.ts", distFile: "account-id.js" }, - { - subpath: "keyed-async-queue", - srcFile: "keyed-async-queue.ts", - distFile: "keyed-async-queue.js", - }, -] as const; +const cachedPluginSdkExportedSubpaths = new Map(); + +function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: path.dirname(modulePath), + }); + if (!packageRoot) { + return []; + } + const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); + if (cached) { + return cached; + } + try { + const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); + const pkg = JSON.parse(pkgRaw) as { + exports?: Record; + }; + const subpaths = Object.keys(pkg.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)) + .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) + .toSorted(); + cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); + return subpaths; + } catch { + return []; + } +} const resolvePluginSdkScopedAliasMap = (): Record => { const aliasMap: Record = {}; - for (const entry of pluginSdkScopedAliasEntries) { + for (const subpath of listPluginSdkExportedSubpaths()) { const resolved = resolvePluginSdkAliasFile({ - srcFile: entry.srcFile, - distFile: entry.distFile, + srcFile: `${subpath}.ts`, + distFile: `${subpath}.js`, }); if (resolved) { - aliasMap[`openclaw/plugin-sdk/${entry.subpath}`] = resolved; + aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; } } return aliasMap; @@ -217,6 +159,7 @@ const resolvePluginSdkScopedAliasMap = (): Record => { export const __testing = { listPluginSdkAliasCandidates, + listPluginSdkExportedSubpaths, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, }; From f6cb77134c1444a3a10b1329c1e43de40ec84f00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:11:05 +0000 Subject: [PATCH 122/311] refactor: centralize acp session resolution guards --- src/acp/control-plane/manager.core.ts | 106 ++++-------------- src/acp/control-plane/manager.utils.ts | 24 ++++ .../reply/commands-acp/lifecycle.ts | 21 +--- 3 files changed, 50 insertions(+), 101 deletions(-) diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 4d45a7693a9a6..8c4822d46a630 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -49,7 +49,9 @@ import { normalizeAcpErrorCode, normalizeActorKey, normalizeSessionKey, + requireReadySessionMeta, resolveAcpAgentFromSessionKey, + resolveAcpSessionResolutionError, resolveMissingMetaError, resolveRuntimeIdleTtlMs, } from "./manager.utils.js"; @@ -332,15 +334,7 @@ export class AcpSessionManager { cfg: params.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const { runtime, handle: ensuredHandle, @@ -348,7 +342,7 @@ export class AcpSessionManager { } = await this.ensureRuntimeHandle({ cfg: params.cfg, sessionKey, - meta: resolution.meta, + meta: resolvedMeta, }); let handle = ensuredHandle; let meta = ensuredMeta; @@ -414,19 +408,11 @@ export class AcpSessionManager { cfg: params.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const { runtime, handle, meta } = await this.ensureRuntimeHandle({ cfg: params.cfg, sessionKey, - meta: resolution.meta, + meta: resolvedMeta, }); const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle }); if (!capabilities.controls.includes("session/set_mode") || !runtime.setMode) { @@ -479,19 +465,11 @@ export class AcpSessionManager { cfg: params.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const { runtime, handle, meta } = await this.ensureRuntimeHandle({ cfg: params.cfg, sessionKey, - meta: resolution.meta, + meta: resolvedMeta, }); const inferredPatch = inferRuntimeOptionPatchFromConfigOption(key, value); const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle }); @@ -558,17 +536,9 @@ export class AcpSessionManager { cfg: params.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const nextOptions = mergeRuntimeOptions({ - current: resolveRuntimeOptionsFromMeta(resolution.meta), + current: resolveRuntimeOptionsFromMeta(resolvedMeta), patch: validatedPatch, }); await this.persistRuntimeOptions({ @@ -594,19 +564,11 @@ export class AcpSessionManager { cfg: params.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const { runtime, handle } = await this.ensureRuntimeHandle({ cfg: params.cfg, sessionKey, - meta: resolution.meta, + meta: resolvedMeta, }); await withAcpRuntimeErrorBoundary({ run: async () => @@ -638,15 +600,7 @@ export class AcpSessionManager { cfg: input.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const { runtime, @@ -655,7 +609,7 @@ export class AcpSessionManager { } = await this.ensureRuntimeHandle({ cfg: input.cfg, sessionKey, - meta: resolution.meta, + meta: resolvedMeta, }); let handle = ensuredHandle; const meta = ensuredMeta; @@ -810,19 +764,11 @@ export class AcpSessionManager { cfg: params.cfg, sessionKey, }); - if (resolution.kind === "none") { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - if (resolution.kind === "stale") { - throw resolution.error; - } + const resolvedMeta = requireReadySessionMeta(resolution); const { runtime, handle } = await this.ensureRuntimeHandle({ cfg: params.cfg, sessionKey, - meta: resolution.meta, + meta: resolvedMeta, }); try { await withAcpRuntimeErrorBoundary({ @@ -868,27 +814,17 @@ export class AcpSessionManager { cfg: input.cfg, sessionKey, }); - if (resolution.kind === "none") { - if (input.requireAcpSession ?? true) { - throw new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${sessionKey}`, - ); - } - return { - runtimeClosed: false, - metaCleared: false, - }; - } - if (resolution.kind === "stale") { + const resolutionError = resolveAcpSessionResolutionError(resolution); + if (resolutionError) { if (input.requireAcpSession ?? true) { - throw resolution.error; + throw resolutionError; } return { runtimeClosed: false, metaCleared: false, }; } + const meta = resolution.meta; let runtimeClosed = false; let runtimeNotice: string | undefined; @@ -896,7 +832,7 @@ export class AcpSessionManager { const { runtime, handle } = await this.ensureRuntimeHandle({ cfg: input.cfg, sessionKey, - meta: resolution.meta, + meta, }); await withAcpRuntimeErrorBoundary({ run: async () => diff --git a/src/acp/control-plane/manager.utils.ts b/src/acp/control-plane/manager.utils.ts index 3b6b2dacc45df..8360f9bfb8a49 100644 --- a/src/acp/control-plane/manager.utils.ts +++ b/src/acp/control-plane/manager.utils.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionAcpMeta } from "../../config/sessions/types.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js"; +import type { AcpSessionResolution } from "./manager.types.js"; export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string { const parsed = parseAgentSessionKey(sessionKey); @@ -15,6 +16,29 @@ export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError { ); } +export function resolveAcpSessionResolutionError( + resolution: AcpSessionResolution, +): AcpRuntimeError | null { + if (resolution.kind === "ready") { + return null; + } + if (resolution.kind === "stale") { + return resolution.error; + } + return new AcpRuntimeError( + "ACP_SESSION_INIT_FAILED", + `Session is not ACP-enabled: ${resolution.sessionKey}`, + ); +} + +export function requireReadySessionMeta(resolution: AcpSessionResolution): SessionAcpMeta { + const error = resolveAcpSessionResolutionError(resolution); + if (error) { + throw error; + } + return resolution.meta; +} + export function normalizeSessionKey(sessionKey: string): string { return sessionKey.trim(); } diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 43896f3ada350..564788f78d705 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { getAcpSessionManager } from "../../../acp/control-plane/manager.js"; +import { resolveAcpSessionResolutionError } from "../../../acp/control-plane/manager.utils.js"; import { cleanupFailedAcpSpawn, type AcpSpawnRuntimeCloseHandle, @@ -10,7 +11,6 @@ import { resolveAcpDispatchPolicyError, resolveAcpDispatchPolicyMessage, } from "../../../acp/policy.js"; -import { AcpRuntimeError } from "../../../acp/runtime/errors.js"; import { resolveAcpSessionCwd, resolveAcpThreadSessionDetailLines, @@ -390,24 +390,13 @@ function resolveAcpSessionForCommandOrStop(params: { cfg: params.cfg, sessionKey: params.sessionKey, }); - if (resolved.kind === "none") { + const error = resolveAcpSessionResolutionError(resolved); + if (error) { return stopWithText( collectAcpErrorText({ - error: new AcpRuntimeError( - "ACP_SESSION_INIT_FAILED", - `Session is not ACP-enabled: ${params.sessionKey}`, - ), + error, fallbackCode: "ACP_SESSION_INIT_FAILED", - fallbackMessage: "Session is not ACP-enabled.", - }), - ); - } - if (resolved.kind === "stale") { - return stopWithText( - collectAcpErrorText({ - error: resolved.error, - fallbackCode: "ACP_SESSION_INIT_FAILED", - fallbackMessage: resolved.error.message, + fallbackMessage: error.message, }), ); } From 11be305609450ec8700f67375eae00252be8b916 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:13:18 +0000 Subject: [PATCH 123/311] refactor: neutralize context engine runtime bridge --- src/agents/pi-embedded-runner/compact.ts | 2 +- src/agents/pi-embedded-runner/run.ts | 2 +- src/agents/pi-embedded-runner/run/attempt.test.ts | 11 +++++------ src/agents/pi-embedded-runner/run/attempt.ts | 8 ++++---- src/context-engine/context-engine.test.ts | 2 +- src/context-engine/legacy.ts | 15 ++++++++------- src/context-engine/types.ts | 13 +++++++------ 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 3a51da222719b..ad5cecd8bd2ed 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -933,7 +933,7 @@ export async function compactEmbeddedPiSession( tokenBudget: ceCtxInfo.tokens, customInstructions: params.customInstructions, force: params.trigger === "manual", - legacyParams: params as Record, + runtimeContext: params as Record, }); return { ok: result.ok, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index c763fbd2a94cc..c96089a9f55a4 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1025,7 +1025,7 @@ export async function runEmbeddedPiAgent( tokenBudget: ctxInfo.tokens, force: true, compactionTarget: "budget", - legacyParams: { + runtimeContext: { sessionKey: params.sessionKey, messageChannel: params.messageChannel, messageProvider: params.messageProvider, diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 649679632e83f..76c4253aa4b53 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { resolveOllamaBaseUrlForRun } from "../../ollama-stream.js"; import { - buildAfterTurnLegacyCompactionParams, + buildAfterTurnRuntimeContext, composeSystemPromptWithHookContext, isOllamaCompatProvider, prependSystemPromptAddition, @@ -638,9 +638,9 @@ describe("prependSystemPromptAddition", () => { }); }); -describe("buildAfterTurnLegacyCompactionParams", () => { +describe("buildAfterTurnRuntimeContext", () => { it("uses primary model when compaction.model is not set", () => { - const legacy = buildAfterTurnLegacyCompactionParams({ + const legacy = buildAfterTurnRuntimeContext({ attempt: { sessionKey: "agent:main:session:abc", messageChannel: "slack", @@ -668,7 +668,7 @@ describe("buildAfterTurnLegacyCompactionParams", () => { }); it("passes primary model through even when compaction.model is set (override resolved in compactDirect)", () => { - const legacy = buildAfterTurnLegacyCompactionParams({ + const legacy = buildAfterTurnRuntimeContext({ attempt: { sessionKey: "agent:main:session:abc", messageChannel: "slack", @@ -704,9 +704,8 @@ describe("buildAfterTurnLegacyCompactionParams", () => { model: "gpt-5.3-codex", }); }); - it("includes resolved auth profile fields for context-engine afterTurn compaction", () => { - const legacy = buildAfterTurnLegacyCompactionParams({ + const legacy = buildAfterTurnRuntimeContext({ attempt: { sessionKey: "agent:main:session:abc", messageChannel: "slack", diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 467b8e1501fd3..b57159e52aa02 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -636,8 +636,8 @@ export function prependSystemPromptAddition(params: { return `${params.systemPromptAddition}\n\n${params.systemPrompt}`; } -/** Build legacy compaction params passed into context-engine afterTurn hooks. */ -export function buildAfterTurnLegacyCompactionParams(params: { +/** Build runtime context passed into context-engine afterTurn hooks. */ +export function buildAfterTurnRuntimeContext(params: { attempt: Pick< EmbeddedRunAttemptParams, | "sessionKey" @@ -1884,7 +1884,7 @@ export async function runEmbeddedAttempt( // Let the active context engine run its post-turn lifecycle. if (params.contextEngine) { - const afterTurnLegacyCompactionParams = buildAfterTurnLegacyCompactionParams({ + const afterTurnRuntimeContext = buildAfterTurnRuntimeContext({ attempt: params, workspaceDir: effectiveWorkspace, agentDir, @@ -1898,7 +1898,7 @@ export async function runEmbeddedAttempt( messages: messagesSnapshot, prePromptMessageCount, tokenBudget: params.contextTokenBudget, - legacyCompactionParams: afterTurnLegacyCompactionParams, + runtimeContext: afterTurnRuntimeContext, }); } catch (afterTurnErr) { log.warn(`context engine afterTurn failed: ${String(afterTurnErr)}`); diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 022fdc14cc8e8..e6788d2f86cdd 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -67,7 +67,7 @@ class MockContextEngine implements ContextEngine { tokenBudget?: number; compactionTarget?: "budget" | "threshold"; customInstructions?: string; - legacyParams?: Record; + runtimeContext?: Record; }): Promise { return { ok: true, diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index ab2eeff9b7fc1..011022ae26a57 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -5,6 +5,7 @@ import type { ContextEngineInfo, AssembleResult, CompactResult, + ContextEngineRuntimeContext, IngestResult, } from "./types.js"; @@ -54,7 +55,7 @@ export class LegacyContextEngine implements ContextEngine { autoCompactionSummary?: string; isHeartbeat?: boolean; tokenBudget?: number; - legacyCompactionParams?: Record; + runtimeContext?: ContextEngineRuntimeContext; }): Promise { // No-op: legacy flow persists context directly in SessionManager. } @@ -67,26 +68,26 @@ export class LegacyContextEngine implements ContextEngine { currentTokenCount?: number; compactionTarget?: "budget" | "threshold"; customInstructions?: string; - legacyParams?: Record; + runtimeContext?: ContextEngineRuntimeContext; }): Promise { // Import through a dedicated runtime boundary so the lazy edge remains effective. const { compactEmbeddedPiSessionDirect } = await import("../agents/pi-embedded-runner/compact.runtime.js"); - // legacyParams carries the full CompactEmbeddedPiSessionParams fields + // runtimeContext carries the full CompactEmbeddedPiSessionParams fields // set by the caller in run.ts. We spread them and override the fields // that come from the ContextEngine compact() signature directly. - const lp = params.legacyParams ?? {}; + const runtimeContext = params.runtimeContext ?? {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- legacy bridge: legacyParams is an opaque bag matching CompactEmbeddedPiSessionParams + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge runtimeContext matches CompactEmbeddedPiSessionParams const result = await compactEmbeddedPiSessionDirect({ - ...lp, + ...runtimeContext, sessionId: params.sessionId, sessionFile: params.sessionFile, tokenBudget: params.tokenBudget, force: params.force, customInstructions: params.customInstructions, - workspaceDir: (lp.workspaceDir as string) ?? process.cwd(), + workspaceDir: (runtimeContext.workspaceDir as string) ?? process.cwd(), } as Parameters[0]); return { diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 525c673b092d0..b886190a1e0e9 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -57,6 +57,7 @@ export type SubagentSpawnPreparation = { }; export type SubagentEndReason = "deleted" | "completed" | "swept" | "released"; +export type ContextEngineRuntimeContext = Record; /** * ContextEngine defines the pluggable contract for context management. @@ -110,8 +111,8 @@ export interface ContextEngine { isHeartbeat?: boolean; /** Optional model context token budget for proactive compaction. */ tokenBudget?: number; - /** Backward-compat only: legacy compaction bridge runtime params. */ - legacyCompactionParams?: Record; + /** Optional runtime-owned context for engines that need caller state. */ + runtimeContext?: ContextEngineRuntimeContext; }): Promise; /** @@ -132,15 +133,15 @@ export interface ContextEngine { sessionId: string; sessionFile: string; tokenBudget?: number; - /** Backward-compat only: force legacy compaction behavior even below threshold. */ + /** Force compaction even below the default trigger threshold. */ force?: boolean; /** Optional live token estimate from the caller's active context. */ currentTokenCount?: number; - /** Controls convergence target; defaults to budget for compatibility. */ + /** Controls convergence target; defaults to budget. */ compactionTarget?: "budget" | "threshold"; customInstructions?: string; - /** Backward-compat only: full params bag for legacy compaction bridge. */ - legacyParams?: Record; + /** Optional runtime-owned context for engines that need caller state. */ + runtimeContext?: ContextEngineRuntimeContext; }): Promise; /** From 6bd5735519b435d9efc6278c97ef51b718207b1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:14:46 +0000 Subject: [PATCH 124/311] refactor: split doctor config analysis helpers --- src/commands/doctor-config-analysis.test.ts | 34 ++++ src/commands/doctor-config-analysis.ts | 152 ++++++++++++++++++ src/commands/doctor-config-flow.ts | 165 ++------------------ 3 files changed, 196 insertions(+), 155 deletions(-) create mode 100644 src/commands/doctor-config-analysis.test.ts create mode 100644 src/commands/doctor-config-analysis.ts diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts new file mode 100644 index 0000000000000..f9f2dafa64662 --- /dev/null +++ b/src/commands/doctor-config-analysis.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + formatConfigPath, + resolveConfigPathTarget, + stripUnknownConfigKeys, +} from "./doctor-config-analysis.js"; + +describe("doctor config analysis helpers", () => { + it("formats config paths predictably", () => { + expect(formatConfigPath([])).toBe(""); + expect(formatConfigPath(["channels", "slack", "accounts", 0, "token"])).toBe( + "channels.slack.accounts[0].token", + ); + }); + + it("resolves nested config targets without throwing", () => { + const target = resolveConfigPathTarget( + { channels: { slack: { accounts: [{ token: "x" }] } } }, + ["channels", "slack", "accounts", 0], + ); + expect(target).toEqual({ token: "x" }); + expect(resolveConfigPathTarget({ channels: null }, ["channels", "slack"])).toBeNull(); + }); + + it("strips unknown config keys while keeping known values", () => { + const result = stripUnknownConfigKeys({ + hooks: {}, + unexpected: true, + } as never); + expect(result.removed).toContain("unexpected"); + expect((result.config as Record).unexpected).toBeUndefined(); + expect((result.config as Record).hooks).toEqual({}); + }); +}); diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts new file mode 100644 index 0000000000000..dea3fa1b3f203 --- /dev/null +++ b/src/commands/doctor-config-analysis.ts @@ -0,0 +1,152 @@ +import path from "node:path"; +import type { ZodIssue } from "zod"; +import type { OpenClawConfig } from "../config/config.js"; +import { CONFIG_PATH } from "../config/config.js"; +import { OpenClawSchema } from "../config/zod-schema.js"; +import { note } from "../terminal/note.js"; +import { isRecord } from "../utils.js"; + +type UnrecognizedKeysIssue = ZodIssue & { + code: "unrecognized_keys"; + keys: PropertyKey[]; +}; + +function normalizeIssuePath(path: PropertyKey[]): Array { + return path.filter((part): part is string | number => typeof part !== "symbol"); +} + +function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue { + return issue.code === "unrecognized_keys"; +} + +export function formatConfigPath(parts: Array): string { + if (parts.length === 0) { + return ""; + } + let out = ""; + for (const part of parts) { + if (typeof part === "number") { + out += `[${part}]`; + continue; + } + out = out ? `${out}.${part}` : part; + } + return out || ""; +} + +export function resolveConfigPathTarget(root: unknown, path: Array): unknown { + let current: unknown = root; + for (const part of path) { + if (typeof part === "number") { + if (!Array.isArray(current)) { + return null; + } + if (part < 0 || part >= current.length) { + return null; + } + current = current[part]; + continue; + } + if (!current || typeof current !== "object" || Array.isArray(current)) { + return null; + } + const record = current as Record; + if (!(part in record)) { + return null; + } + current = record[part]; + } + return current; +} + +export function stripUnknownConfigKeys(config: OpenClawConfig): { + config: OpenClawConfig; + removed: string[]; +} { + const parsed = OpenClawSchema.safeParse(config); + if (parsed.success) { + return { config, removed: [] }; + } + + const next = structuredClone(config); + const removed: string[] = []; + for (const issue of parsed.error.issues) { + if (!isUnrecognizedKeysIssue(issue)) { + continue; + } + const issuePath = normalizeIssuePath(issue.path); + const target = resolveConfigPathTarget(next, issuePath); + if (!target || typeof target !== "object" || Array.isArray(target)) { + continue; + } + const record = target as Record; + for (const key of issue.keys) { + if (typeof key !== "string" || !(key in record)) { + continue; + } + delete record[key]; + removed.push(formatConfigPath([...issuePath, key])); + } + } + + return { config: next, removed }; +} + +export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void { + const providers = cfg.models?.providers; + if (!providers) { + return; + } + + const overrides: string[] = []; + if (providers.opencode) { + overrides.push("opencode"); + } + if (providers["opencode-zen"]) { + overrides.push("opencode-zen"); + } + if (overrides.length === 0) { + return; + } + + const lines = overrides.flatMap((id) => { + const providerEntry = providers[id]; + const api = + isRecord(providerEntry) && typeof providerEntry.api === "string" + ? providerEntry.api + : undefined; + return [ + `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, + api ? `- models.providers.${id}.api=${api}` : null, + ].filter((line): line is string => Boolean(line)); + }); + + lines.push( + "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", + ); + note(lines.join("\n"), "OpenCode Zen"); +} + +export function noteIncludeConfinementWarning(snapshot: { + path?: string | null; + issues?: Array<{ message: string }>; +}): void { + const issues = snapshot.issues ?? []; + const includeIssue = issues.find( + (issue) => + issue.message.includes("Include path escapes config directory") || + issue.message.includes("Include path resolves outside config directory"), + ); + if (!includeIssue) { + return; + } + const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH); + note( + [ + `- $include paths must stay under: ${configRoot}`, + '- Move shared include files under that directory and update to relative paths like "./shared/common.json".', + `- Error: ${includeIssue.message}`, + ].join("\n"), + "Doctor warnings", + ); +} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 289b6b047cb07..ff97c001f0795 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { ZodIssue } from "zod"; import { normalizeChatChannelId } from "../channels/registry.js"; import { isNumericTelegramUserId, @@ -17,7 +16,6 @@ import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous- import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; -import { OpenClawSchema } from "../config/zod-schema.js"; import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { listInterpreterLikeSafeBins, @@ -50,161 +48,18 @@ import { import { inspectTelegramAccount } from "../telegram/account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { note } from "../terminal/note.js"; -import { isRecord, resolveHomeDir } from "../utils.js"; +import { resolveHomeDir } from "../utils.js"; +import { + formatConfigPath, + noteIncludeConfinementWarning, + noteOpencodeProviderOverrides, + resolveConfigPathTarget, + stripUnknownConfigKeys, +} from "./doctor-config-analysis.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js"; -type UnrecognizedKeysIssue = ZodIssue & { - code: "unrecognized_keys"; - keys: PropertyKey[]; -}; - -function normalizeIssuePath(path: PropertyKey[]): Array { - return path.filter((part): part is string | number => typeof part !== "symbol"); -} - -function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue { - return issue.code === "unrecognized_keys"; -} - -function formatPath(parts: Array): string { - if (parts.length === 0) { - return ""; - } - let out = ""; - for (const part of parts) { - if (typeof part === "number") { - out += `[${part}]`; - continue; - } - out = out ? `${out}.${part}` : part; - } - return out || ""; -} - -function resolvePathTarget(root: unknown, path: Array): unknown { - let current: unknown = root; - for (const part of path) { - if (typeof part === "number") { - if (!Array.isArray(current)) { - return null; - } - if (part < 0 || part >= current.length) { - return null; - } - current = current[part]; - continue; - } - if (!current || typeof current !== "object" || Array.isArray(current)) { - return null; - } - const record = current as Record; - if (!(part in record)) { - return null; - } - current = record[part]; - } - return current; -} - -function stripUnknownConfigKeys(config: OpenClawConfig): { - config: OpenClawConfig; - removed: string[]; -} { - const parsed = OpenClawSchema.safeParse(config); - if (parsed.success) { - return { config, removed: [] }; - } - - const next = structuredClone(config); - const removed: string[] = []; - for (const issue of parsed.error.issues) { - if (!isUnrecognizedKeysIssue(issue)) { - continue; - } - const path = normalizeIssuePath(issue.path); - const target = resolvePathTarget(next, path); - if (!target || typeof target !== "object" || Array.isArray(target)) { - continue; - } - const record = target as Record; - for (const key of issue.keys) { - if (typeof key !== "string") { - continue; - } - if (!(key in record)) { - continue; - } - delete record[key]; - removed.push(formatPath([...path, key])); - } - } - - return { config: next, removed }; -} - -function noteOpencodeProviderOverrides(cfg: OpenClawConfig) { - const providers = cfg.models?.providers; - if (!providers) { - return; - } - - // 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae). - const overrides: string[] = []; - if (providers.opencode) { - overrides.push("opencode"); - } - if (providers["opencode-zen"]) { - overrides.push("opencode-zen"); - } - if (overrides.length === 0) { - return; - } - - const lines = overrides.flatMap((id) => { - const providerEntry = providers[id]; - const api = - isRecord(providerEntry) && typeof providerEntry.api === "string" - ? providerEntry.api - : undefined; - return [ - `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, - api ? `- models.providers.${id}.api=${api}` : null, - ].filter((line): line is string => Boolean(line)); - }); - - lines.push( - "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", - ); - - note(lines.join("\n"), "OpenCode Zen"); -} - -function noteIncludeConfinementWarning(snapshot: { - path?: string | null; - issues?: Array<{ message: string }>; -}): void { - const issues = snapshot.issues ?? []; - const includeIssue = issues.find( - (issue) => - issue.message.includes("Include path escapes config directory") || - issue.message.includes("Include path resolves outside config directory"), - ); - if (!includeIssue) { - return; - } - const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH); - note( - [ - `- $include paths must stay under: ${configRoot}`, - '- Move shared include files under that directory and update to relative paths like "./shared/common.json".', - `- Error: ${includeIssue.message}`, - ].join("\n"), - "Doctor warnings", - ); -} - type TelegramAllowFromUsernameHit = { path: string; entry: string }; type TelegramAllowFromListRef = { @@ -1659,7 +1514,7 @@ function collectLegacyToolsBySenderKeyHits( const toolsBySender = asObjectRecord(record.toolsBySender); if (toolsBySender) { const path = [...pathParts, "toolsBySender"]; - const pathLabel = formatPath(path); + const pathLabel = formatConfigPath(path); for (const rawKey of Object.keys(toolsBySender)) { const trimmed = rawKey.trim(); if (!trimmed || trimmed === "*" || parseToolsBySenderTypedKey(trimmed)) { @@ -1702,7 +1557,7 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): { let changed = false; for (const hit of hits) { - const toolsBySender = asObjectRecord(resolvePathTarget(next, hit.toolsBySenderPath)); + const toolsBySender = asObjectRecord(resolveConfigPathTarget(next, hit.toolsBySenderPath)); if (!toolsBySender || !(hit.key in toolsBySender)) { continue; } From f399a818ef6882ab57b7d926f4cacba6a8cb3cb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:18:28 +0000 Subject: [PATCH 125/311] refactor: extract ios watch reply coordinator --- apps/ios/Sources/Model/NodeAppModel.swift | 36 ++++----------- .../Sources/Model/WatchReplyCoordinator.swift | 46 +++++++++++++++++++ apps/ios/SwiftSources.input.xcfilelist | 1 + 3 files changed, 57 insertions(+), 26 deletions(-) create mode 100644 apps/ios/Sources/Model/WatchReplyCoordinator.swift diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 34826aefeaf7f..34b6876822bdf 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -129,8 +129,7 @@ final class NodeAppModel { private var backgroundReconnectSuppressed = false private var backgroundReconnectLeaseUntil: Date? private var lastSignificantLocationWakeAt: Date? - private var queuedWatchReplies: [WatchQuickReplyEvent] = [] - private var seenWatchReplyIds = Set() + @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator() private var gatewayConnected = false private var operatorConnected = false @@ -2199,37 +2198,22 @@ extension NodeAppModel { } private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { - let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) - let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) - if replyId.isEmpty || actionId.isEmpty { + switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) { + case .dropMissingFields: self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") - return - } - - if self.seenWatchReplyIds.contains(replyId) { + case .deduped(let replyId): self.watchReplyLogger.debug( "watch reply deduped replyId=\(replyId, privacy: .public)") - return - } - self.seenWatchReplyIds.insert(replyId) - - if await !self.isGatewayConnected() { - self.queuedWatchReplies.append(event) + case .queue(let replyId, let actionId): self.watchReplyLogger.info( "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)") - return + case .forward: + await self.forwardWatchReplyToAgent(event) } - - await self.forwardWatchReplyToAgent(event) } private func flushQueuedWatchRepliesIfConnected() async { - guard await self.isGatewayConnected() else { return } - guard !self.queuedWatchReplies.isEmpty else { return } - - let pending = self.queuedWatchReplies - self.queuedWatchReplies.removeAll() - for event in pending { + for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) { await self.forwardWatchReplyToAgent(event) } } @@ -2259,7 +2243,7 @@ extension NodeAppModel { "watch reply forwarding failed replyId=\(event.replyId) " + "error=\(error.localizedDescription)" self.watchReplyLogger.error("\(failedMessage, privacy: .public)") - self.queuedWatchReplies.insert(event, at: 0) + self.watchReplyCoordinator.requeueFront(event) } } @@ -2852,7 +2836,7 @@ extension NodeAppModel { } func _test_queuedWatchReplyCount() -> Int { - self.queuedWatchReplies.count + self.watchReplyCoordinator.queuedCount } func _test_setGatewayConnected(_ connected: Bool) { diff --git a/apps/ios/Sources/Model/WatchReplyCoordinator.swift b/apps/ios/Sources/Model/WatchReplyCoordinator.swift new file mode 100644 index 0000000000000..bdd183d35772c --- /dev/null +++ b/apps/ios/Sources/Model/WatchReplyCoordinator.swift @@ -0,0 +1,46 @@ +import Foundation + +@MainActor +final class WatchReplyCoordinator { + enum Decision { + case dropMissingFields + case deduped(replyId: String) + case queue(replyId: String, actionId: String) + case forward + } + + private var queuedReplies: [WatchQuickReplyEvent] = [] + private var seenReplyIds = Set() + + func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision { + let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) + if replyId.isEmpty || actionId.isEmpty { + return .dropMissingFields + } + if self.seenReplyIds.contains(replyId) { + return .deduped(replyId: replyId) + } + self.seenReplyIds.insert(replyId) + if !isGatewayConnected { + self.queuedReplies.append(event) + return .queue(replyId: replyId, actionId: actionId) + } + return .forward + } + + func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] { + guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] } + let pending = self.queuedReplies + self.queuedReplies.removeAll() + return pending + } + + func requeueFront(_ event: WatchQuickReplyEvent) { + self.queuedReplies.insert(event, at: 0) + } + + var queuedCount: Int { + self.queuedReplies.count + } +} diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index c94ef48fa3222..ad55607e9a40c 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -13,6 +13,7 @@ Sources/OpenClawApp.swift Sources/Location/LocationService.swift Sources/Model/NodeAppModel.swift Sources/Model/NodeAppModel+Canvas.swift +Sources/Model/WatchReplyCoordinator.swift Sources/RootCanvas.swift Sources/RootTabs.swift Sources/Screen/ScreenController.swift From 68775745d239c25c5860a27b5c483c052c9d6781 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:21:17 +0000 Subject: [PATCH 126/311] fix: restore acp session meta narrowing --- src/acp/control-plane/manager.core.ts | 2 +- src/acp/control-plane/manager.utils.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 8c4822d46a630..a64b1fae7eba5 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -824,7 +824,7 @@ export class AcpSessionManager { metaCleared: false, }; } - const meta = resolution.meta; + const meta = requireReadySessionMeta(resolution); let runtimeClosed = false; let runtimeNotice: string | undefined; diff --git a/src/acp/control-plane/manager.utils.ts b/src/acp/control-plane/manager.utils.ts index 8360f9bfb8a49..17729c6c2fc0b 100644 --- a/src/acp/control-plane/manager.utils.ts +++ b/src/acp/control-plane/manager.utils.ts @@ -32,11 +32,10 @@ export function resolveAcpSessionResolutionError( } export function requireReadySessionMeta(resolution: AcpSessionResolution): SessionAcpMeta { - const error = resolveAcpSessionResolutionError(resolution); - if (error) { - throw error; + if (resolution.kind === "ready") { + return resolution.meta; } - return resolution.meta; + throw resolveAcpSessionResolutionError(resolution); } export function normalizeSessionKey(sessionKey: string): string { From c5095153b0b7e72b5a792d476e3f5a6b7d4094a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:21:28 +0000 Subject: [PATCH 127/311] refactor: extract qmd process runner --- src/memory/qmd-manager.ts | 210 ++++++-------------------------------- src/memory/qmd-process.ts | 144 ++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 179 deletions(-) create mode 100644 src/memory/qmd-process.ts diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 0c7a2185f9f6b..7efe8f10af5e5 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -8,11 +7,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { writeFileWithinRoot } from "../infra/fs-safe.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - materializeWindowsSpawnProgram, - resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; import { isFileMissingError, statRegularFile } from "./fs-utils.js"; +import { + isWindowsCommandShimEinval, + resolveCliSpawnInvocation, + runCliCommand, +} from "./qmd-process.js"; import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; import { listSessionFilesForAgent, @@ -51,53 +51,6 @@ const QMD_BM25_HAN_KEYWORD_LIMIT = 12; let qmdEmbedQueueTail: Promise = Promise.resolve(); -function resolveWindowsCommandShim(command: string): string { - if (process.platform !== "win32") { - return command; - } - const trimmed = command.trim(); - if (!trimmed) { - return command; - } - const ext = path.extname(trimmed).toLowerCase(); - if (ext === ".cmd" || ext === ".exe" || ext === ".bat") { - return command; - } - const base = path.basename(trimmed).toLowerCase(); - if (base === "qmd" || base === "mcporter") { - return `${trimmed}.cmd`; - } - return command; -} - -function resolveSpawnInvocation(params: { - command: string; - args: string[]; - env: NodeJS.ProcessEnv; - packageName: string; -}) { - const program = resolveWindowsSpawnProgram({ - command: resolveWindowsCommandShim(params.command), - platform: process.platform, - env: params.env, - execPath: process.execPath, - packageName: params.packageName, - allowShellFallback: true, - }); - return materializeWindowsSpawnProgram(program, params.args); -} - -function isWindowsCmdSpawnEinval(err: unknown, command: string): boolean { - if (process.platform !== "win32") { - return false; - } - const errno = err as NodeJS.ErrnoException | undefined; - if (errno?.code !== "EINVAL") { - return false; - } - return /(^|[\\/])mcporter\.cmd$/i.test(command); -} - function hasHanScript(value: string): boolean { return HAN_SCRIPT_RE.test(value); } @@ -1235,70 +1188,20 @@ export class QmdMemoryManager implements MemorySearchManager { args: string[], opts?: { timeoutMs?: number; discardOutput?: boolean }, ): Promise<{ stdout: string; stderr: string }> { - return await new Promise((resolve, reject) => { - const spawnInvocation = resolveSpawnInvocation({ + return await runCliCommand({ + commandSummary: `qmd ${args.join(" ")}`, + spawnInvocation: resolveCliSpawnInvocation({ command: this.qmd.command, args, env: this.env, packageName: "qmd", - }); - const child = spawn(spawnInvocation.command, spawnInvocation.argv, { - env: this.env, - cwd: this.workspaceDir, - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, - }); - let stdout = ""; - let stderr = ""; - let stdoutTruncated = false; - let stderrTruncated = false; - // When discardOutput is set, skip stdout accumulation entirely and keep - // only a small stderr tail for diagnostics -- never fail on truncation. - // This prevents large `qmd update` runs from hitting the output cap. - const discard = opts?.discardOutput === true; - const timer = opts?.timeoutMs - ? setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`qmd ${args.join(" ")} timed out after ${opts.timeoutMs}ms`)); - }, opts.timeoutMs) - : null; - child.stdout.on("data", (data) => { - if (discard) { - return; // drain without accumulating - } - const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); - stdout = next.text; - stdoutTruncated = stdoutTruncated || next.truncated; - }); - child.stderr.on("data", (data) => { - const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); - stderr = next.text; - stderrTruncated = stderrTruncated || next.truncated; - }); - child.on("error", (err) => { - if (timer) { - clearTimeout(timer); - } - reject(err); - }); - child.on("close", (code) => { - if (timer) { - clearTimeout(timer); - } - if (!discard && (stdoutTruncated || stderrTruncated)) { - reject( - new Error( - `qmd ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, - ), - ); - return; - } - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(`qmd ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`)); - } - }); + }), + env: this.env, + cwd: this.workspaceDir, + timeoutMs: opts?.timeoutMs, + maxOutputChars: this.maxQmdOutputChars, + // Large `qmd update` runs can easily exceed the output cap; keep only stderr. + discardStdout: opts?.discardOutput, }); } @@ -1347,62 +1250,17 @@ export class QmdMemoryManager implements MemorySearchManager { shell?: boolean; windowsHide?: boolean; }): Promise<{ stdout: string; stderr: string }> => - await new Promise((resolve, reject) => { - const commandSummary = `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`; - const child = spawn(spawnInvocation.command, spawnInvocation.argv, { - // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. - env: this.env, - cwd: this.workspaceDir, - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, - }); - let stdout = ""; - let stderr = ""; - let stdoutTruncated = false; - let stderrTruncated = false; - const timer = opts?.timeoutMs - ? setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`)); - }, opts.timeoutMs) - : null; - child.stdout.on("data", (data) => { - const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); - stdout = next.text; - stdoutTruncated = stdoutTruncated || next.truncated; - }); - child.stderr.on("data", (data) => { - const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); - stderr = next.text; - stderrTruncated = stderrTruncated || next.truncated; - }); - child.on("error", (err) => { - if (timer) { - clearTimeout(timer); - } - reject(err); - }); - child.on("close", (code) => { - if (timer) { - clearTimeout(timer); - } - if (stdoutTruncated || stderrTruncated) { - reject( - new Error( - `mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, - ), - ); - return; - } - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(`${commandSummary} failed (code ${code}): ${stderr || stdout}`)); - } - }); + await runCliCommand({ + commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`, + spawnInvocation, + // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. + env: this.env, + cwd: this.workspaceDir, + timeoutMs: opts?.timeoutMs, + maxOutputChars: this.maxQmdOutputChars, }); - const primaryInvocation = resolveSpawnInvocation({ + const primaryInvocation = resolveCliSpawnInvocation({ command: "mcporter", args, env: this.env, @@ -1411,7 +1269,13 @@ export class QmdMemoryManager implements MemorySearchManager { try { return await runWithInvocation(primaryInvocation); } catch (err) { - if (!isWindowsCmdSpawnEinval(err, primaryInvocation.command)) { + if ( + !isWindowsCommandShimEinval({ + err, + command: primaryInvocation.command, + commandBase: "mcporter", + }) + ) { throw err; } // Some Windows npm cmd shims can still throw EINVAL on spawn; retry through @@ -2232,15 +2096,3 @@ export class QmdMemoryManager implements MemorySearchManager { return [command, normalizedQuery, "--json", "-n", String(limit)]; } } - -function appendOutputWithCap( - current: string, - chunk: string, - maxChars: number, -): { text: string; truncated: boolean } { - const appended = current + chunk; - if (appended.length <= maxChars) { - return { text: appended, truncated: false }; - } - return { text: appended.slice(-maxChars), truncated: true }; -} diff --git a/src/memory/qmd-process.ts b/src/memory/qmd-process.ts new file mode 100644 index 0000000000000..7c0b1a6c3bae0 --- /dev/null +++ b/src/memory/qmd-process.ts @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { + materializeWindowsSpawnProgram, + resolveWindowsSpawnProgram, +} from "../plugin-sdk/windows-spawn.js"; + +export type CliSpawnInvocation = { + command: string; + argv: string[]; + shell?: boolean; + windowsHide?: boolean; +}; + +function resolveWindowsCommandShim(command: string): string { + if (process.platform !== "win32") { + return command; + } + const trimmed = command.trim(); + if (!trimmed) { + return command; + } + const ext = path.extname(trimmed).toLowerCase(); + if (ext === ".cmd" || ext === ".exe" || ext === ".bat") { + return command; + } + const base = path.basename(trimmed).toLowerCase(); + if (base === "qmd" || base === "mcporter") { + return `${trimmed}.cmd`; + } + return command; +} + +export function resolveCliSpawnInvocation(params: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + packageName: string; +}): CliSpawnInvocation { + const program = resolveWindowsSpawnProgram({ + command: resolveWindowsCommandShim(params.command), + platform: process.platform, + env: params.env, + execPath: process.execPath, + packageName: params.packageName, + allowShellFallback: true, + }); + return materializeWindowsSpawnProgram(program, params.args); +} + +export function isWindowsCommandShimEinval(params: { + err: unknown; + command: string; + commandBase: string; +}): boolean { + if (process.platform !== "win32") { + return false; + } + const errno = params.err as NodeJS.ErrnoException | undefined; + if (errno?.code !== "EINVAL") { + return false; + } + const escapedBase = params.commandBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\\\/])${escapedBase}\\.cmd$`, "i").test(params.command); +} + +export async function runCliCommand(params: { + commandSummary: string; + spawnInvocation: CliSpawnInvocation; + env: NodeJS.ProcessEnv; + cwd: string; + timeoutMs?: number; + maxOutputChars: number; + discardStdout?: boolean; +}): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolve, reject) => { + const child = spawn(params.spawnInvocation.command, params.spawnInvocation.argv, { + env: params.env, + cwd: params.cwd, + shell: params.spawnInvocation.shell, + windowsHide: params.spawnInvocation.windowsHide, + }); + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + const discardStdout = params.discardStdout === true; + const timer = params.timeoutMs + ? setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`${params.commandSummary} timed out after ${params.timeoutMs}ms`)); + }, params.timeoutMs) + : null; + child.stdout.on("data", (data) => { + if (discardStdout) { + return; + } + const next = appendOutputWithCap(stdout, data.toString("utf8"), params.maxOutputChars); + stdout = next.text; + stdoutTruncated = stdoutTruncated || next.truncated; + }); + child.stderr.on("data", (data) => { + const next = appendOutputWithCap(stderr, data.toString("utf8"), params.maxOutputChars); + stderr = next.text; + stderrTruncated = stderrTruncated || next.truncated; + }); + child.on("error", (err) => { + if (timer) { + clearTimeout(timer); + } + reject(err); + }); + child.on("close", (code) => { + if (timer) { + clearTimeout(timer); + } + if (!discardStdout && (stdoutTruncated || stderrTruncated)) { + reject( + new Error( + `${params.commandSummary} produced too much output (limit ${params.maxOutputChars} chars)`, + ), + ); + return; + } + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`${params.commandSummary} failed (code ${code}): ${stderr || stdout}`)); + } + }); + }); +} + +function appendOutputWithCap( + current: string, + chunk: string, + maxChars: number, +): { text: string; truncated: boolean } { + const appended = current + chunk; + if (appended.length <= maxChars) { + return { text: appended, truncated: false }; + } + return { text: appended.slice(-maxChars), truncated: true }; +} From 3ada30e6707e117ceb394a934e2d8be424a0ea59 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 18:39:37 +0000 Subject: [PATCH 128/311] fix: restore gate after rebase --- scripts/test-parallel.mjs | 4 ++- .../pi-embedded-runner-extraparams.test.ts | 2 +- src/agents/provider-capabilities.ts | 2 +- src/infra/git-commit.test.ts | 32 ++++++++++++------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 65cad7ee6f10d..67129a24aea8e 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -123,7 +123,9 @@ const testProfile = rawTestProfile === "serial" ? rawTestProfile : "normal"; -const shouldSplitUnitRuns = testProfile !== "low" && testProfile !== "serial"; +// Even on low-memory hosts, keep the isolated lane split so files like +// git-commit.test.ts still get the worker/process isolation they require. +const shouldSplitUnitRuns = testProfile !== "serial"; const runs = [ ...(shouldSplitUnitRuns ? [ diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index fb3569369caea..f0762e02f0a7b 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -914,7 +914,7 @@ describe("applyExtraParamsToAgent", () => { compat: { requiresOpenAiAnthropicToolPayload: true, }, - } as Model<"anthropic-messages">; + } as unknown as Model<"anthropic-messages">; const context: Context = { messages: [] }; void agent.streamFn?.(model, context, {}); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 807c72e22f8bd..d12a3f0b94ef6 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -157,5 +157,5 @@ export function resolveTranscriptToolCallIdMode( if (modelIncludesAnyHint(modelId, capabilities.transcriptToolCallIdModelHints)) { return "strict9"; } - return mode === "strict9" ? mode : undefined; + return undefined; } diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 61804e6f95f58..be89c5444b431 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -3,8 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import process from "node:process"; -import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; async function makeTempDir(label: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); @@ -39,10 +39,20 @@ async function makeFakeGitRepo( } describe("git commit resolution", () => { - const originalCwd = process.cwd(); + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + + beforeEach(async () => { + process.chdir(repoRoot); + vi.restoreAllMocks(); + vi.doUnmock("node:fs"); + vi.doUnmock("node:module"); + vi.resetModules(); + const { __testing } = await import("./git-commit.js"); + __testing.clearCachedGitCommits(); + }); afterEach(async () => { - process.chdir(originalCwd); + process.chdir(repoRoot); vi.restoreAllMocks(); vi.doUnmock("node:fs"); vi.doUnmock("node:module"); @@ -53,7 +63,7 @@ describe("git commit resolution", () => { it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { - cwd: originalCwd, + cwd: repoRoot, encoding: "utf-8", }).trim(); @@ -75,7 +85,7 @@ describe("git commit resolution", () => { process.chdir(otherRepo); const { resolveCommitHash } = await import("./git-commit.js"); - const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; + const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).toBe(repoHead); expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).not.toBe(otherHead); @@ -83,12 +93,12 @@ describe("git commit resolution", () => { it("prefers live git metadata over stale build info in a real checkout", async () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { - cwd: originalCwd, + cwd: repoRoot, encoding: "utf-8", }).trim(); const { resolveCommitHash } = await import("./git-commit.js"); - const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; + const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; expect( resolveCommitHash({ @@ -149,16 +159,16 @@ describe("git commit resolution", () => { it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { - cwd: originalCwd, + cwd: repoRoot, encoding: "utf-8", }).trim(); const { resolveCommitHash } = await import("./git-commit.js"); expect(() => - resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} }), + resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} }), ).not.toThrow(); - expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} })).toBe( + expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: repoRoot, env: {} })).toBe( repoHead, ); }); From 885199dcaa683834cd929f8c033deb7076c415fb Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:06:05 -0800 Subject: [PATCH 129/311] docs: add Browserbase as hosted remote CDP option Add Browserbase documentation section alongside the existing Browserless section in the browser docs. Includes signup instructions, CDP connection configuration, and environment variable setup for both English and Chinese (zh-CN) translations. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 81 +++++++++++++++++++++++++++++++++++++ docs/zh-CN/tools/browser.md | 65 +++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 70c420b6c3353..ea1a20cd3bd61 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -196,6 +196,87 @@ Notes: - Replace `` with your real Browserless token. - Choose the region endpoint that matches your Browserless account (see their docs). +## Browserbase (hosted remote CDP) + +[Browserbase](https://www.browserbase.com) is a cloud platform for running +headless browsers. It provides remote CDP endpoints with built-in CAPTCHA +solving, anti-bot stealth mode, and residential proxies. You can point an +OpenClaw browser profile at a Browserbase session and authenticate with your +API key. + +### Getting started + +1. **Sign up** at [browserbase.com/sign-up](https://www.browserbase.com/sign-up). + The free tier includes one concurrent browser session and 60 minutes of + monthly usage. +2. **Find your credentials** on the + [Overview dashboard](https://www.browserbase.com/overview) — copy your + **API Key** and **Project ID** from the right-hand panel. +3. **Create a session** using the Browserbase API (or their SDK) and retrieve + the CDP connect URL. + +### Configuration + +Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "browserbase", + remoteCdpTimeoutMs: 3000, + remoteCdpHandshakeTimeoutMs: 5000, + profiles: { + browserbase: { + cdpUrl: "wss://connect.browserbase.com?apiKey=", + color: "#F97316", + }, + }, + }, +} +``` + +If you create sessions through the Browserbase Sessions API, each session +returns its own `connectUrl`. You can use that directly: + +```json5 +{ + browser: { + profiles: { + browserbase: { + cdpUrl: "", + color: "#F97316", + }, + }, + }, +} +``` + +### Environment variables + +Store your credentials in environment variables instead of committing them to +config: + +```bash +export BROWSERBASE_API_KEY="bb_live_..." +export BROWSERBASE_PROJECT_ID="your-project-id" +``` + +Then reference them in your profile or create sessions programmatically. + +### Notes + +- Replace `` with your real Browserbase API key (starts + with `bb_live_`). +- The free tier allows one concurrent session and 60 minutes per month. Paid + plans offer higher concurrency and usage limits. +- Browserbase sessions include automatic CAPTCHA solving and anti-bot stealth + by default — no extra configuration needed. +- Sessions can be monitored live at + `https://www.browserbase.com/sessions/`. +- See the [Browserbase docs](https://docs.browserbase.com) for full API + reference, SDK guides, and integration examples. + ## Security Key ideas: diff --git a/docs/zh-CN/tools/browser.md b/docs/zh-CN/tools/browser.md index 048a98703fb35..2d364b14a0699 100644 --- a/docs/zh-CN/tools/browser.md +++ b/docs/zh-CN/tools/browser.md @@ -178,6 +178,71 @@ OpenClaw 在调用 `/json/*` 端点和连接 CDP WebSocket 时会保留认证信 - 将 `` 替换为你真实的 Browserless 令牌。 - 选择与你的 Browserless 账户匹配的区域端点(请参阅其文档)。 +## Browserbase(托管远程 CDP) + +[Browserbase](https://www.browserbase.com) 是一个云平台,用于运行无头浏览器。它提供远程 CDP 端点,内置验证码自动解决、反机器人隐身模式和住宅代理。你可以将 OpenClaw 浏览器配置文件指向 Browserbase 会话,并使用你的 API 密钥进行认证。 + +### 快速开始 + +1. 在 [browserbase.com/sign-up](https://www.browserbase.com/sign-up) **注册**。免费版包含一个并发浏览器会话和每月 60 分钟的使用时间。 +2. 在[概览面板](https://www.browserbase.com/overview)中**查找你的凭据** — 从右侧面板复制你的 **API Key** 和 **Project ID**。 +3. 使用 Browserbase API(或其 SDK)**创建会话**,并获取 CDP 连接 URL。 + +### 配置 + +Browserbase 会话暴露 WebSocket CDP 端点。将配置文件指向它: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "browserbase", + remoteCdpTimeoutMs: 3000, + remoteCdpHandshakeTimeoutMs: 5000, + profiles: { + browserbase: { + cdpUrl: "wss://connect.browserbase.com?apiKey=", + color: "#F97316", + }, + }, + }, +} +``` + +如果你通过 Browserbase Sessions API 创建会话,每个会话会返回自己的 `connectUrl`。你可以直接使用: + +```json5 +{ + browser: { + profiles: { + browserbase: { + cdpUrl: "", + color: "#F97316", + }, + }, + }, +} +``` + +### 环境变量 + +将凭据存储在环境变量中,而不是提交到配置文件: + +```bash +export BROWSERBASE_API_KEY="bb_live_..." +export BROWSERBASE_PROJECT_ID="your-project-id" +``` + +然后在配置文件中引用它们,或以编程方式创建会话。 + +### 注意事项 + +- 将 `` 替换为你真实的 Browserbase API 密钥(以 `bb_live_` 开头)。 +- 免费版允许一个并发会话和每月 60 分钟。付费计划提供更高的并发数和使用限额。 +- Browserbase 会话默认包含自动验证码解决和反机器人隐身功能 — 无需额外配置。 +- 可以在 `https://www.browserbase.com/sessions/` 实时监控会话。 +- 请参阅 [Browserbase 文档](https://docs.browserbase.com) 获取完整的 API 参考、SDK 指南和集成示例。 + ## 安全性 核心理念: From 0252bdc837f0706e185cf50cadf84fccc20be5b7 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:07:53 -0800 Subject: [PATCH 130/311] Revert "docs: add Browserbase as hosted remote CDP option" This reverts commit c469657c97848c7a3e1e5135bf4ce735d07d6614. --- docs/tools/browser.md | 81 ------------------------------------- docs/zh-CN/tools/browser.md | 65 ----------------------------- 2 files changed, 146 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index ea1a20cd3bd61..70c420b6c3353 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -196,87 +196,6 @@ Notes: - Replace `` with your real Browserless token. - Choose the region endpoint that matches your Browserless account (see their docs). -## Browserbase (hosted remote CDP) - -[Browserbase](https://www.browserbase.com) is a cloud platform for running -headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, anti-bot stealth mode, and residential proxies. You can point an -OpenClaw browser profile at a Browserbase session and authenticate with your -API key. - -### Getting started - -1. **Sign up** at [browserbase.com/sign-up](https://www.browserbase.com/sign-up). - The free tier includes one concurrent browser session and 60 minutes of - monthly usage. -2. **Find your credentials** on the - [Overview dashboard](https://www.browserbase.com/overview) — copy your - **API Key** and **Project ID** from the right-hand panel. -3. **Create a session** using the Browserbase API (or their SDK) and retrieve - the CDP connect URL. - -### Configuration - -Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: - -```json5 -{ - browser: { - enabled: true, - defaultProfile: "browserbase", - remoteCdpTimeoutMs: 3000, - remoteCdpHandshakeTimeoutMs: 5000, - profiles: { - browserbase: { - cdpUrl: "wss://connect.browserbase.com?apiKey=", - color: "#F97316", - }, - }, - }, -} -``` - -If you create sessions through the Browserbase Sessions API, each session -returns its own `connectUrl`. You can use that directly: - -```json5 -{ - browser: { - profiles: { - browserbase: { - cdpUrl: "", - color: "#F97316", - }, - }, - }, -} -``` - -### Environment variables - -Store your credentials in environment variables instead of committing them to -config: - -```bash -export BROWSERBASE_API_KEY="bb_live_..." -export BROWSERBASE_PROJECT_ID="your-project-id" -``` - -Then reference them in your profile or create sessions programmatically. - -### Notes - -- Replace `` with your real Browserbase API key (starts - with `bb_live_`). -- The free tier allows one concurrent session and 60 minutes per month. Paid - plans offer higher concurrency and usage limits. -- Browserbase sessions include automatic CAPTCHA solving and anti-bot stealth - by default — no extra configuration needed. -- Sessions can be monitored live at - `https://www.browserbase.com/sessions/`. -- See the [Browserbase docs](https://docs.browserbase.com) for full API - reference, SDK guides, and integration examples. - ## Security Key ideas: diff --git a/docs/zh-CN/tools/browser.md b/docs/zh-CN/tools/browser.md index 2d364b14a0699..048a98703fb35 100644 --- a/docs/zh-CN/tools/browser.md +++ b/docs/zh-CN/tools/browser.md @@ -178,71 +178,6 @@ OpenClaw 在调用 `/json/*` 端点和连接 CDP WebSocket 时会保留认证信 - 将 `` 替换为你真实的 Browserless 令牌。 - 选择与你的 Browserless 账户匹配的区域端点(请参阅其文档)。 -## Browserbase(托管远程 CDP) - -[Browserbase](https://www.browserbase.com) 是一个云平台,用于运行无头浏览器。它提供远程 CDP 端点,内置验证码自动解决、反机器人隐身模式和住宅代理。你可以将 OpenClaw 浏览器配置文件指向 Browserbase 会话,并使用你的 API 密钥进行认证。 - -### 快速开始 - -1. 在 [browserbase.com/sign-up](https://www.browserbase.com/sign-up) **注册**。免费版包含一个并发浏览器会话和每月 60 分钟的使用时间。 -2. 在[概览面板](https://www.browserbase.com/overview)中**查找你的凭据** — 从右侧面板复制你的 **API Key** 和 **Project ID**。 -3. 使用 Browserbase API(或其 SDK)**创建会话**,并获取 CDP 连接 URL。 - -### 配置 - -Browserbase 会话暴露 WebSocket CDP 端点。将配置文件指向它: - -```json5 -{ - browser: { - enabled: true, - defaultProfile: "browserbase", - remoteCdpTimeoutMs: 3000, - remoteCdpHandshakeTimeoutMs: 5000, - profiles: { - browserbase: { - cdpUrl: "wss://connect.browserbase.com?apiKey=", - color: "#F97316", - }, - }, - }, -} -``` - -如果你通过 Browserbase Sessions API 创建会话,每个会话会返回自己的 `connectUrl`。你可以直接使用: - -```json5 -{ - browser: { - profiles: { - browserbase: { - cdpUrl: "", - color: "#F97316", - }, - }, - }, -} -``` - -### 环境变量 - -将凭据存储在环境变量中,而不是提交到配置文件: - -```bash -export BROWSERBASE_API_KEY="bb_live_..." -export BROWSERBASE_PROJECT_ID="your-project-id" -``` - -然后在配置文件中引用它们,或以编程方式创建会话。 - -### 注意事项 - -- 将 `` 替换为你真实的 Browserbase API 密钥(以 `bb_live_` 开头)。 -- 免费版允许一个并发会话和每月 60 分钟。付费计划提供更高的并发数和使用限额。 -- Browserbase 会话默认包含自动验证码解决和反机器人隐身功能 — 无需额外配置。 -- 可以在 `https://www.browserbase.com/sessions/` 实时监控会话。 -- 请参阅 [Browserbase 文档](https://docs.browserbase.com) 获取完整的 API 参考、SDK 指南和集成示例。 - ## 安全性 核心理念: From 641e1bacb43f6eb74ede1f2999671ac21180e3ff Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:08:01 -0800 Subject: [PATCH 131/311] docs: add Browserbase as hosted remote CDP option Add Browserbase documentation section alongside the existing Browserless section in the browser docs. Includes signup instructions, CDP connection configuration, and environment variable setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 70c420b6c3353..ea1a20cd3bd61 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -196,6 +196,87 @@ Notes: - Replace `` with your real Browserless token. - Choose the region endpoint that matches your Browserless account (see their docs). +## Browserbase (hosted remote CDP) + +[Browserbase](https://www.browserbase.com) is a cloud platform for running +headless browsers. It provides remote CDP endpoints with built-in CAPTCHA +solving, anti-bot stealth mode, and residential proxies. You can point an +OpenClaw browser profile at a Browserbase session and authenticate with your +API key. + +### Getting started + +1. **Sign up** at [browserbase.com/sign-up](https://www.browserbase.com/sign-up). + The free tier includes one concurrent browser session and 60 minutes of + monthly usage. +2. **Find your credentials** on the + [Overview dashboard](https://www.browserbase.com/overview) — copy your + **API Key** and **Project ID** from the right-hand panel. +3. **Create a session** using the Browserbase API (or their SDK) and retrieve + the CDP connect URL. + +### Configuration + +Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "browserbase", + remoteCdpTimeoutMs: 3000, + remoteCdpHandshakeTimeoutMs: 5000, + profiles: { + browserbase: { + cdpUrl: "wss://connect.browserbase.com?apiKey=", + color: "#F97316", + }, + }, + }, +} +``` + +If you create sessions through the Browserbase Sessions API, each session +returns its own `connectUrl`. You can use that directly: + +```json5 +{ + browser: { + profiles: { + browserbase: { + cdpUrl: "", + color: "#F97316", + }, + }, + }, +} +``` + +### Environment variables + +Store your credentials in environment variables instead of committing them to +config: + +```bash +export BROWSERBASE_API_KEY="bb_live_..." +export BROWSERBASE_PROJECT_ID="your-project-id" +``` + +Then reference them in your profile or create sessions programmatically. + +### Notes + +- Replace `` with your real Browserbase API key (starts + with `bb_live_`). +- The free tier allows one concurrent session and 60 minutes per month. Paid + plans offer higher concurrency and usage limits. +- Browserbase sessions include automatic CAPTCHA solving and anti-bot stealth + by default — no extra configuration needed. +- Sessions can be monitored live at + `https://www.browserbase.com/sessions/`. +- See the [Browserbase docs](https://docs.browserbase.com) for full API + reference, SDK guides, and integration examples. + ## Security Key ideas: From c0a988f692049623eb6cd4c698e08dce490ee586 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:21:26 -0800 Subject: [PATCH 132/311] docs: fix duplicate heading lint error Rename "Configuration" sub-heading to "Profile setup" to avoid MD024/no-duplicate-heading conflict with the existing top-level "Configuration" heading. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index ea1a20cd3bd61..af0fb19ac14e7 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -215,7 +215,7 @@ API key. 3. **Create a session** using the Browserbase API (or their SDK) and retrieve the CDP connect URL. -### Configuration +### Profile setup Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: From 9a4610c6411bbebd9593c1747c67be0286469b32 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:33:01 -0800 Subject: [PATCH 133/311] docs: fix Browserbase section to match official docs Browserbase requires creating a session via their API to get a CDP connect URL, unlike Browserless which uses a static endpoint. Updated to show the correct curl-based session creation flow, removed unverified static WebSocket URL, and added the 5-minute connect timeout note from official docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 52 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index af0fb19ac14e7..42797bfc3b44f 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -200,9 +200,11 @@ Notes: [Browserbase](https://www.browserbase.com) is a cloud platform for running headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, anti-bot stealth mode, and residential proxies. You can point an -OpenClaw browser profile at a Browserbase session and authenticate with your -API key. +solving, anti-bot stealth mode, and residential proxies. + +Unlike Browserless (which exposes a static CDP endpoint), Browserbase requires +you to **create a session** first via their API — each session returns a unique +`connectUrl` that you then set as the `cdpUrl` in your OpenClaw profile. ### Getting started @@ -212,12 +214,25 @@ API key. 2. **Find your credentials** on the [Overview dashboard](https://www.browserbase.com/overview) — copy your **API Key** and **Project ID** from the right-hand panel. -3. **Create a session** using the Browserbase API (or their SDK) and retrieve - the CDP connect URL. + +### Creating a session + +Create a session via the Browserbase API to get a CDP connect URL: + +```bash +curl --request POST \ + --url "https://api.browserbase.com/v1/sessions" \ + --header "Content-Type: application/json" \ + --header "x-bb-api-key: $BROWSERBASE_API_KEY" \ + --data '{ "projectId": "'$BROWSERBASE_PROJECT_ID'" }' +``` + +The response includes a `connectUrl` field — a WebSocket CDP endpoint for that +session. You must connect within **5 minutes** or the session will time out. ### Profile setup -Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: +Set the `connectUrl` from the session response as `cdpUrl` in your profile: ```json5 { @@ -228,7 +243,7 @@ Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: remoteCdpHandshakeTimeoutMs: 5000, profiles: { browserbase: { - cdpUrl: "wss://connect.browserbase.com?apiKey=", + cdpUrl: "", color: "#F97316", }, }, @@ -236,21 +251,8 @@ Browserbase sessions expose a WebSocket CDP endpoint. Point a profile at it: } ``` -If you create sessions through the Browserbase Sessions API, each session -returns its own `connectUrl`. You can use that directly: - -```json5 -{ - browser: { - profiles: { - browserbase: { - cdpUrl: "", - color: "#F97316", - }, - }, - }, -} -``` +Since each session produces a new `connectUrl`, you will need to update the +`cdpUrl` value each time you create a new session. ### Environment variables @@ -258,16 +260,12 @@ Store your credentials in environment variables instead of committing them to config: ```bash -export BROWSERBASE_API_KEY="bb_live_..." +export BROWSERBASE_API_KEY="your-api-key" export BROWSERBASE_PROJECT_ID="your-project-id" ``` -Then reference them in your profile or create sessions programmatically. - ### Notes -- Replace `` with your real Browserbase API key (starts - with `bb_live_`). - The free tier allows one concurrent session and 60 minutes per month. Paid plans offer higher concurrency and usage limits. - Browserbase sessions include automatic CAPTCHA solving and anti-bot stealth From efa1204183bf48fdfe8dfaedb5f93d072b6a38d8 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:36:08 -0800 Subject: [PATCH 134/311] docs: restore direct wss://connect.browserbase.com URL Browserbase exposes a direct WebSocket connect endpoint that auto-creates a session, similar to how Browserless works. Simplified the section to use this static URL pattern instead of requiring manual session creation via the API. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 45 +++++++------------------------------------ 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 42797bfc3b44f..e631b68855f2d 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -200,11 +200,9 @@ Notes: [Browserbase](https://www.browserbase.com) is a cloud platform for running headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, anti-bot stealth mode, and residential proxies. - -Unlike Browserless (which exposes a static CDP endpoint), Browserbase requires -you to **create a session** first via their API — each session returns a unique -`connectUrl` that you then set as the `cdpUrl` in your OpenClaw profile. +solving, anti-bot stealth mode, and residential proxies. You can point an +OpenClaw browser profile at Browserbase's connect endpoint and authenticate +with your API key. ### Getting started @@ -213,26 +211,11 @@ you to **create a session** first via their API — each session returns a uniqu monthly usage. 2. **Find your credentials** on the [Overview dashboard](https://www.browserbase.com/overview) — copy your - **API Key** and **Project ID** from the right-hand panel. - -### Creating a session - -Create a session via the Browserbase API to get a CDP connect URL: - -```bash -curl --request POST \ - --url "https://api.browserbase.com/v1/sessions" \ - --header "Content-Type: application/json" \ - --header "x-bb-api-key: $BROWSERBASE_API_KEY" \ - --data '{ "projectId": "'$BROWSERBASE_PROJECT_ID'" }' -``` - -The response includes a `connectUrl` field — a WebSocket CDP endpoint for that -session. You must connect within **5 minutes** or the session will time out. + **API Key** from the right-hand panel. ### Profile setup -Set the `connectUrl` from the session response as `cdpUrl` in your profile: +Point a profile at Browserbase's connect endpoint with your API key: ```json5 { @@ -243,7 +226,7 @@ Set the `connectUrl` from the session response as `cdpUrl` in your profile: remoteCdpHandshakeTimeoutMs: 5000, profiles: { browserbase: { - cdpUrl: "", + cdpUrl: "wss://connect.browserbase.com?apiKey=", color: "#F97316", }, }, @@ -251,27 +234,13 @@ Set the `connectUrl` from the session response as `cdpUrl` in your profile: } ``` -Since each session produces a new `connectUrl`, you will need to update the -`cdpUrl` value each time you create a new session. - -### Environment variables - -Store your credentials in environment variables instead of committing them to -config: - -```bash -export BROWSERBASE_API_KEY="your-api-key" -export BROWSERBASE_PROJECT_ID="your-project-id" -``` - ### Notes +- Replace `` with your real Browserbase API key. - The free tier allows one concurrent session and 60 minutes per month. Paid plans offer higher concurrency and usage limits. - Browserbase sessions include automatic CAPTCHA solving and anti-bot stealth by default — no extra configuration needed. -- Sessions can be monitored live at - `https://www.browserbase.com/sessions/`. - See the [Browserbase docs](https://docs.browserbase.com) for full API reference, SDK guides, and integration examples. From ae39a152d8563540cc0f690aa873cc1085174941 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:51:50 -0800 Subject: [PATCH 135/311] docs: fact-check Browserbase section against official docs - Fix CAPTCHA/stealth/proxy claims: these are Developer plan+ only, not available on free tier - Fix free tier limits: 1 browser hour, 15-min session duration (not "60 minutes of monthly usage") - Add link to pricing page for paid plan details - Simplify structure to match Browserless section format - Remove sub-headings to match Browserless section style Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index e631b68855f2d..6f59659d78d60 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -199,23 +199,12 @@ Notes: ## Browserbase (hosted remote CDP) [Browserbase](https://www.browserbase.com) is a cloud platform for running -headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, anti-bot stealth mode, and residential proxies. You can point an +headless browsers with remote CDP endpoints. Paid plans (Developer and above) +add CAPTCHA solving, stealth mode, and residential proxies. You can point an OpenClaw browser profile at Browserbase's connect endpoint and authenticate with your API key. -### Getting started - -1. **Sign up** at [browserbase.com/sign-up](https://www.browserbase.com/sign-up). - The free tier includes one concurrent browser session and 60 minutes of - monthly usage. -2. **Find your credentials** on the - [Overview dashboard](https://www.browserbase.com/overview) — copy your - **API Key** from the right-hand panel. - -### Profile setup - -Point a profile at Browserbase's connect endpoint with your API key: +Example: ```json5 { @@ -234,13 +223,16 @@ Point a profile at Browserbase's connect endpoint with your API key: } ``` -### Notes +Notes: +- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key** + from the [Overview dashboard](https://www.browserbase.com/overview). - Replace `` with your real Browserbase API key. -- The free tier allows one concurrent session and 60 minutes per month. Paid - plans offer higher concurrency and usage limits. -- Browserbase sessions include automatic CAPTCHA solving and anti-bot stealth - by default — no extra configuration needed. +- The free tier allows one concurrent session, 15-minute session duration, and + one browser hour per month. See [pricing](https://www.browserbase.com/pricing) + for paid plan limits. +- CAPTCHA solving, stealth mode, and proxies require the Developer plan + ($20/month) or above. - See the [Browserbase docs](https://docs.browserbase.com) for full API reference, SDK guides, and integration examples. From 3cf75f760c0f89adbad9415b3d5fdb5b83f2dd82 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sun, 1 Mar 2026 16:52:49 -0800 Subject: [PATCH 136/311] docs: simplify Browserbase section, drop pricing details Restore platform-level feature description (CAPTCHA solving, stealth mode, proxies) without plan-specific pricing gating. Keep free tier note brief. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/tools/browser.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 6f59659d78d60..efd8874c9931c 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -199,8 +199,8 @@ Notes: ## Browserbase (hosted remote CDP) [Browserbase](https://www.browserbase.com) is a cloud platform for running -headless browsers with remote CDP endpoints. Paid plans (Developer and above) -add CAPTCHA solving, stealth mode, and residential proxies. You can point an +headless browsers. It provides remote CDP endpoints with built-in CAPTCHA +solving, stealth mode, and residential proxies. You can point an OpenClaw browser profile at Browserbase's connect endpoint and authenticate with your API key. @@ -228,11 +228,8 @@ Notes: - [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key** from the [Overview dashboard](https://www.browserbase.com/overview). - Replace `` with your real Browserbase API key. -- The free tier allows one concurrent session, 15-minute session duration, and - one browser hour per month. See [pricing](https://www.browserbase.com/pricing) - for paid plan limits. -- CAPTCHA solving, stealth mode, and proxies require the Developer plan - ($20/month) or above. +- The free tier allows one concurrent session and one browser hour per month. + See [pricing](https://www.browserbase.com/pricing) for paid plan limits. - See the [Browserbase docs](https://docs.browserbase.com) for full API reference, SDK guides, and integration examples. From 75602014dbc5088b80e9b236146dfe5fdcc59e20 Mon Sep 17 00:00:00 2001 From: shrey150 Date: Mon, 2 Mar 2026 00:49:55 -0800 Subject: [PATCH 137/311] feat(browser): support direct WebSocket CDP URLs for Browserbase Browserbase uses direct WebSocket connections (wss://) rather than the standard HTTP-based /json/version CDP discovery flow used by Browserless. This change teaches the browser tool to accept ws:// and wss:// URLs as cdpUrl values: when a WebSocket URL is detected, OpenClaw connects directly instead of attempting HTTP discovery. Changes: - config.ts: accept ws:// and wss:// in cdpUrl validation - cdp.helpers.ts: add isWebSocketUrl() helper - cdp.ts: skip /json/version when cdpUrl is already a WebSocket URL - chrome.ts: probe WSS endpoints via WebSocket handshake instead of HTTP - cdp.test.ts: add test for direct WebSocket target creation - docs/tools/browser.md: update Browserbase section with correct URL format and notes Co-Authored-By: Claude Opus 4.6 --- docs/tools/browser.md | 9 ++++++--- src/browser/cdp.helpers.ts | 14 ++++++++++++++ src/browser/cdp.test.ts | 28 +++++++++++++++++++++++++++ src/browser/cdp.ts | 39 ++++++++++++++++++++++++++++---------- src/browser/chrome.ts | 25 +++++++++++++++++++++++- src/browser/config.ts | 8 +++++--- 6 files changed, 106 insertions(+), 17 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index efd8874c9931c..d02feaf3b552e 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -200,9 +200,10 @@ Notes: [Browserbase](https://www.browserbase.com) is a cloud platform for running headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, stealth mode, and residential proxies. You can point an -OpenClaw browser profile at Browserbase's connect endpoint and authenticate -with your API key. +solving, stealth mode, and residential proxies. Unlike Browserless (which +exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a +direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com` +and authenticates via your API key in the query string. Example: @@ -228,6 +229,8 @@ Notes: - [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key** from the [Overview dashboard](https://www.browserbase.com/overview). - Replace `` with your real Browserbase API key. +- Browserbase auto-creates a browser session on WebSocket connect, so no + manual session creation step is needed. - The free tier allows one concurrent session and one browser hour per month. See [pricing](https://www.browserbase.com/pricing) for paid plan limits. - See the [Browserbase docs](https://docs.browserbase.com) for full API diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 0ae9d22d80bf8..1072853abfb3c 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -7,6 +7,20 @@ import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; export { isLoopbackHost }; +/** + * Returns true when the URL uses a WebSocket protocol (ws: or wss:). + * Used to distinguish direct-WebSocket CDP endpoints (e.g. Browserbase) + * from HTTP(S) endpoints that require /json/version discovery. + */ +export function isWebSocketUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "ws:" || parsed.protocol === "wss:"; + } catch { + return false; + } +} + type CdpResponse = { id: number; result?: unknown; diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index e8e2b9f6d6a14..8b988c6986002 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -95,6 +95,34 @@ describe("cdp", () => { expect(created.targetId).toBe("TARGET_123"); }); + it("creates a target via direct WebSocket URL (skips /json/version)", async () => { + const wsPort = await startWsServerWithMessages((msg, socket) => { + if (msg.method !== "Target.createTarget") { + return; + } + socket.send( + JSON.stringify({ + id: msg.id, + result: { targetId: "TARGET_WS_DIRECT" }, + }), + ); + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + const created = await createTargetViaCdp({ + cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`, + url: "https://example.com", + }); + + expect(created.targetId).toBe("TARGET_WS_DIRECT"); + // /json/version should NOT have been called — direct WS skips HTTP discovery + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks private navigation targets by default", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 20686b76fed2f..b40b142bf3b07 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -1,8 +1,20 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js"; +import { + appendCdpPath, + fetchJson, + isLoopbackHost, + isWebSocketUrl, + withCdpSocket, +} from "./cdp.helpers.js"; import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; -export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js"; +export { + appendCdpPath, + fetchJson, + fetchOk, + getHeadersWithAuth, + isWebSocketUrl, +} from "./cdp.helpers.js"; export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { const ws = new URL(wsUrl); @@ -94,14 +106,21 @@ export async function createTargetViaCdp(opts: { ...withBrowserNavigationPolicy(opts.ssrfPolicy), }); - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( - appendCdpPath(opts.cdpUrl, "/json/version"), - 1500, - ); - const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim(); - const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : ""; - if (!wsUrl) { - throw new Error("CDP /json/version missing webSocketDebuggerUrl"); + let wsUrl: string; + if (isWebSocketUrl(opts.cdpUrl)) { + // Direct WebSocket URL (e.g. Browserbase) — skip /json/version discovery. + wsUrl = opts.cdpUrl; + } else { + // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version. + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( + appendCdpPath(opts.cdpUrl, "/json/version"), + 1500, + ); + const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim(); + wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : ""; + if (!wsUrl) { + throw new Error("CDP /json/version missing webSocketDebuggerUrl"); + } } return await withCdpSocket(wsUrl, async (send) => { diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index f610b74caaa4b..86ebc70576b3d 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -17,7 +17,7 @@ import { CHROME_STOP_TIMEOUT_MS, CHROME_WS_READY_TIMEOUT_MS, } from "./cdp-timeouts.js"; -import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js"; +import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { type BrowserExecutable, @@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) { return `http://127.0.0.1:${cdpPort}`; } +async function canOpenWebSocket(url: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs }); + ws.once("open", () => { + try { + ws.close(); + } catch { + // ignore + } + resolve(true); + }); + ws.once("error", () => resolve(false)); + }); +} + export async function isChromeReachable( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ): Promise { + if (isWebSocketUrl(cdpUrl)) { + // Direct WebSocket endpoint (e.g. Browserbase) — probe via WS handshake. + return await canOpenWebSocket(cdpUrl, timeoutMs); + } const version = await fetchChromeVersion(cdpUrl, timeoutMs); return Boolean(version); } @@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl( cdpUrl: string, timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ): Promise { + if (isWebSocketUrl(cdpUrl)) { + // Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL. + return cdpUrl; + } const version = await fetchChromeVersion(cdpUrl, timeoutMs); const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); if (!wsUrl) { diff --git a/src/browser/config.ts b/src/browser/config.ts index 336049e8c69ba..b2b953aa31a90 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -129,14 +129,16 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | export function parseHttpUrl(raw: string, label: string) { const trimmed = raw.trim(); const parsed = new URL(trimmed); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`); + const allowed = ["http:", "https:", "ws:", "wss:"]; + if (!allowed.includes(parsed.protocol)) { + throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`); } + const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:"; const port = parsed.port && Number.parseInt(parsed.port, 10) > 0 ? Number.parseInt(parsed.port, 10) - : parsed.protocol === "https:" + : isSecure ? 443 : 80; From f9c220e2613134c3fad6907dc11746cc6a791ef6 Mon Sep 17 00:00:00 2001 From: shrey150 Date: Mon, 2 Mar 2026 01:08:15 -0800 Subject: [PATCH 138/311] test+docs: comprehensive coverage and generic framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 12 new tests covering: isWebSocketUrl detection, parseHttpUrl WSS acceptance/rejection, direct WS target creation with query params, SSRF enforcement on WS URLs, WS reachability probing bypasses HTTP - Reframe docs section as generic "Direct WebSocket CDP providers" with Browserbase as one example — any WSS-based provider works - Update security tips to mention WSS alongside HTTPS Co-Authored-By: Claude Opus 4.6 --- docs/tools/browser.md | 25 +++++---- src/browser/cdp.test.ts | 102 +++++++++++++++++++++++++++++++++++++ src/browser/chrome.test.ts | 10 ++++ 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/docs/tools/browser.md b/docs/tools/browser.md index d02feaf3b552e..e1372a08b9d00 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -196,16 +196,23 @@ Notes: - Replace `` with your real Browserless token. - Choose the region endpoint that matches your Browserless account (see their docs). -## Browserbase (hosted remote CDP) +## Direct WebSocket CDP providers -[Browserbase](https://www.browserbase.com) is a cloud platform for running -headless browsers. It provides remote CDP endpoints with built-in CAPTCHA -solving, stealth mode, and residential proxies. Unlike Browserless (which -exposes a standard HTTP-based CDP discovery endpoint), Browserbase uses a -direct WebSocket connection — OpenClaw connects to `wss://connect.browserbase.com` -and authenticates via your API key in the query string. +Some hosted browser services expose a **direct WebSocket** endpoint rather than +the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both: -Example: +- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to + discover the WebSocket debugger URL, then connects. +- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly, + skipping `/json/version`. Use this for services like + [Browserbase](https://www.browserbase.com) or any provider that hands you a + WebSocket URL. + +### Browserbase + +[Browserbase](https://www.browserbase.com) is a cloud platform for running +headless browsers with built-in CAPTCHA solving, stealth mode, and residential +proxies. ```json5 { @@ -247,7 +254,7 @@ Key ideas: Remote CDP tips: -- Prefer HTTPS endpoints and short-lived tokens where possible. +- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible. - Avoid embedding long-lived tokens directly in config files. ## Profiles (multi-browser) diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 8b988c6986002..9976869b9dd64 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type WebSocket, WebSocketServer } from "ws"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; +import { isWebSocketUrl } from "./cdp.helpers.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; +import { parseHttpUrl } from "./config.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; describe("cdp", () => { @@ -123,6 +125,51 @@ describe("cdp", () => { } }); + it("preserves query params when connecting via direct WebSocket URL", async () => { + let receivedHeaders: Record = {}; + const wsPort = await startWsServer(); + if (!wsServer) { + throw new Error("ws server not initialized"); + } + wsServer.on("headers", (headers, req) => { + receivedHeaders = Object.fromEntries( + Object.entries(req.headers).map(([k, v]) => [k, String(v)]), + ); + }); + wsServer.on("connection", (socket) => { + socket.on("message", (data) => { + const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string }; + if (msg.method === "Target.createTarget") { + socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_QP" } })); + } + }); + }); + + const created = await createTargetViaCdp({ + cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST?apiKey=secret123`, + url: "https://example.com", + }); + expect(created.targetId).toBe("T_QP"); + // The WebSocket upgrade request should have been made to the URL with the query param + expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`); + }); + + it("still enforces SSRF policy for direct WebSocket URLs", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + await expect( + createTargetViaCdp({ + cdpUrl: "ws://127.0.0.1:9222", + url: "http://127.0.0.1:8080", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + // SSRF check happens before any connection attempt + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks private navigation targets by default", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try { @@ -281,3 +328,58 @@ describe("cdp", () => { expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc"); }); }); + +describe("isWebSocketUrl", () => { + it("returns true for ws:// URLs", () => { + expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true); + expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true); + }); + + it("returns true for wss:// URLs", () => { + expect(isWebSocketUrl("wss://connect.example.com")).toBe(true); + expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true); + }); + + it("returns false for http:// and https:// URLs", () => { + expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false); + expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false); + }); + + it("returns false for invalid or non-URL strings", () => { + expect(isWebSocketUrl("not-a-url")).toBe(false); + expect(isWebSocketUrl("")).toBe(false); + expect(isWebSocketUrl("ftp://example.com")).toBe(false); + }); +}); + +describe("parseHttpUrl with WebSocket protocols", () => { + it("accepts wss:// URLs and defaults to port 443", () => { + const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test"); + expect(result.parsed.protocol).toBe("wss:"); + expect(result.port).toBe(443); + expect(result.normalized).toContain("wss://connect.example.com"); + }); + + it("accepts ws:// URLs and defaults to port 80", () => { + const result = parseHttpUrl("ws://127.0.0.1/devtools", "test"); + expect(result.parsed.protocol).toBe("ws:"); + expect(result.port).toBe(80); + }); + + it("preserves explicit ports in wss:// URLs", () => { + const result = parseHttpUrl("wss://connect.example.com:8443/path", "test"); + expect(result.port).toBe(8443); + }); + + it("still accepts http:// and https:// URLs", () => { + const http = parseHttpUrl("http://127.0.0.1:9222", "test"); + expect(http.port).toBe(9222); + const https = parseHttpUrl("https://browserless.example?token=abc", "test"); + expect(https.port).toBe(443); + }); + + it("rejects unsupported protocols", () => { + expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)"); + expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)"); + }); +}); diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index 467a09be0f265..dcbd32fd13c58 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -350,6 +350,16 @@ describe("browser chrome helpers", () => { }); }); + it("probes WebSocket URLs via handshake instead of HTTP", async () => { + // For ws:// URLs, isChromeReachable should NOT call fetch at all — + // it should attempt a WebSocket handshake instead. + const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called")); + vi.stubGlobal("fetch", fetchSpy); + // No WS server listening → handshake fails → not reachable + await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("stopOpenClawChrome no-ops when process is already killed", async () => { const proc = makeChromeTestProc({ killed: true }); await stopChromeWithProc(proc, 10); From 8b2f40f5f643be315ae78d95a520db09af08401a Mon Sep 17 00:00:00 2001 From: shrey150 Date: Mon, 2 Mar 2026 02:02:03 -0800 Subject: [PATCH 139/311] fix(browser): update existing tests for ws/wss protocol support Two pre-existing tests still expected ws:// URLs to be rejected by parseHttpUrl, which now accepts them. Switch the invalid-protocol fixture to ftp:// and tighten the assertion to match the full "must be http(s) or ws(s)" error message. Co-Authored-By: Claude Opus 4.6 --- src/browser/config.test.ts | 4 +++- .../server.post-tabs-open-profile-unknown-returns-404.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index ec1c40cd66e01..5fa1488a22d59 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -166,7 +166,9 @@ describe("browser config", () => { }); it("rejects unsupported protocols", () => { - expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i); + expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( + "must be http(s) or ws(s)", + ); }); it("does not add the built-in chrome extension profile if the derived relay port is already used", () => { diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 26de7ecccacac..b65c74319a380 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -110,7 +110,7 @@ describe("profile CRUD endpoints", () => { const createBadRemote = await realFetch(`${base}/profiles/create`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }), + body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }), }); expect(createBadRemote.status).toBe(400); const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; From c1f6edf48b23319ee0052f34602d5b0028afe312 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Tue, 3 Mar 2026 13:39:06 -0800 Subject: [PATCH 140/311] fix(browser): preserve wss:// cdpUrl in legacy default profile resolution --- src/browser/config.test.ts | 11 +++++++++++ src/browser/config.ts | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 5fa1488a22d59..eb77dc5341da8 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -165,6 +165,17 @@ describe("browser config", () => { expect(work?.cdpUrl).toBe("https://example.com:18801"); }); + it("preserves wss:// cdpUrl with query params for the default profile", () => { + const resolved = resolveBrowserConfig({ + cdpUrl: "wss://connect.browserbase.com?apiKey=test-key", + }); + const profile = resolveProfile(resolved, "openclaw"); + expect(profile?.cdpUrl).toBe("wss://connect.browserbase.com/?apiKey=test-key"); + expect(profile?.cdpHost).toBe("connect.browserbase.com"); + expect(profile?.cdpPort).toBe(443); + expect(profile?.cdpIsLoopback).toBe(false); + }); + it("rejects unsupported protocols", () => { expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( "must be http(s) or ws(s)", diff --git a/src/browser/config.ts b/src/browser/config.ts index b2b953aa31a90..a2f4b5757f434 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -162,12 +162,17 @@ function ensureDefaultProfile( defaultColor: string, legacyCdpPort?: number, derivedDefaultCdpPort?: number, + legacyCdpUrl?: string, ): Record { const result = { ...profiles }; if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) { result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, color: defaultColor, + // Preserve the full cdpUrl for ws/wss endpoints so resolveProfile() + // doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops + // the WebSocket protocol and query params like API keys). + ...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}), }; } return result; @@ -260,8 +265,11 @@ export function resolveBrowserConfig( const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined; // Use legacy cdpUrl port for backward compatibility when no profiles configured const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; + const isWsUrl = + cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; + const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; const profiles = ensureDefaultChromeExtensionProfile( - ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart), + ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart, legacyCdpUrl), controlPort, ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; From 7b58507224579a1a6cff878d747068c25fcfa200 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 5 Mar 2026 09:59:28 -0800 Subject: [PATCH 141/311] chore: remove vendor-specific references from code comments --- src/browser/cdp.helpers.ts | 2 +- src/browser/cdp.ts | 2 +- src/browser/chrome.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 1072853abfb3c..6e360e8028baf 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -9,7 +9,7 @@ export { isLoopbackHost }; /** * Returns true when the URL uses a WebSocket protocol (ws: or wss:). - * Used to distinguish direct-WebSocket CDP endpoints (e.g. Browserbase) + * Used to distinguish direct-WebSocket CDP endpoints * from HTTP(S) endpoints that require /json/version discovery. */ export function isWebSocketUrl(url: string): boolean { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index b40b142bf3b07..1f078bb4a042d 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -108,7 +108,7 @@ export async function createTargetViaCdp(opts: { let wsUrl: string; if (isWebSocketUrl(opts.cdpUrl)) { - // Direct WebSocket URL (e.g. Browserbase) — skip /json/version discovery. + // Direct WebSocket URL — skip /json/version discovery. wsUrl = opts.cdpUrl; } else { // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version. diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 86ebc70576b3d..8e48024d7adb6 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -98,7 +98,7 @@ export async function isChromeReachable( timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS, ): Promise { if (isWebSocketUrl(cdpUrl)) { - // Direct WebSocket endpoint (e.g. Browserbase) — probe via WS handshake. + // Direct WebSocket endpoint — probe via WS handshake. return await canOpenWebSocket(cdpUrl, timeoutMs); } const version = await fetchChromeVersion(cdpUrl, timeoutMs); From 4d904e7b7d6a1847f91f5688522786e9f9ec1870 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 5 Mar 2026 22:57:08 -0800 Subject: [PATCH 142/311] style(browser): fix oxfmt formatting in config.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/browser/config.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/browser/config.ts b/src/browser/config.ts index a2f4b5757f434..f6b6e1b6d014a 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -265,11 +265,16 @@ export function resolveBrowserConfig( const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined; // Use legacy cdpUrl port for backward compatibility when no profiles configured const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; - const isWsUrl = - cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; + const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:"; const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; const profiles = ensureDefaultChromeExtensionProfile( - ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart, legacyCdpUrl), + ensureDefaultProfile( + cfg?.profiles, + defaultColor, + legacyCdpPort, + cdpPortRangeStart, + legacyCdpUrl, + ), controlPort, ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; From 9914b48c578262ded2b2318493fd7e2fc46dd8ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 18:47:48 +0000 Subject: [PATCH 143/311] fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150) --- CHANGELOG.md | 1 + src/browser/browser-utils.test.ts | 18 +++- src/browser/cdp.helpers.ts | 22 ++++ src/browser/pw-session.ts | 30 ++---- .../server-context.loopback-direct-ws.test.ts | 100 ++++++++++++++++++ src/browser/server-context.selection.ts | 8 +- src/browser/server-context.tab-ops.ts | 10 +- 7 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 src/browser/server-context.loopback-direct-ws.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c26849508d415..904126a13ae1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. +- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. ## 2026.3.7 diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index 80ad76c655f26..d5c081668e719 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; +import { + appendCdpPath, + getHeadersWithAuth, + normalizeCdpHttpBaseForJsonEndpoints, +} from "./cdp.helpers.js"; import { __test } from "./client-fetch.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { shouldRejectBrowserMutation } from "./csrf.js"; @@ -155,6 +159,18 @@ describe("cdp.helpers", () => { expect(url).toBe("https://example.com/chrome/json/list?token=abc"); }); + it("normalizes direct WebSocket CDP URLs to an HTTP base for /json endpoints", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints( + "wss://connect.example.com/devtools/browser/ABC?token=abc", + ); + expect(url).toBe("https://connect.example.com/?token=abc"); + }); + + it("strips a trailing /cdp suffix when normalizing HTTP bases", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc"); + expect(url).toBe("http://127.0.0.1:9222/?token=abc"); + }); + it("adds basic auth headers when credentials are present", () => { const headers = getHeadersWithAuth("https://user:pass@example.com"); expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index 6e360e8028baf..5749a591fd633 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -67,6 +67,28 @@ export function appendCdpPath(cdpUrl: string, path: string): string { return url.toString(); } +export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { + try { + const url = new URL(cdpUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); + url.pathname = url.pathname.replace(/\/cdp$/, ""); + return url.toString().replace(/\/$/, ""); + } catch { + // Best-effort fallback for non-URL-ish inputs. + return cdpUrl + .replace(/^ws:/, "http:") + .replace(/^wss:/, "https:") + .replace(/\/devtools\/browser\/.*$/, "") + .replace(/\/cdp$/, "") + .replace(/\/$/, ""); + } +} + function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map(); diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index b657bb2e252cf..55d41c60c0a75 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -10,7 +10,13 @@ import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; -import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js"; +import { + appendCdpPath, + fetchJson, + getHeadersWithAuth, + normalizeCdpHttpBaseForJsonEndpoints, + withCdpSocket, +} from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { getChromeWebSocketUrl } from "./chrome.js"; import { @@ -546,28 +552,6 @@ export async function closePlaywrightBrowserConnection(): Promise { await cur.browser.close().catch(() => {}); } -function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { - try { - const url = new URL(cdpUrl); - if (url.protocol === "ws:") { - url.protocol = "http:"; - } else if (url.protocol === "wss:") { - url.protocol = "https:"; - } - url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, ""); - url.pathname = url.pathname.replace(/\/cdp$/, ""); - return url.toString().replace(/\/$/, ""); - } catch { - // Best-effort fallback for non-URL-ish inputs. - return cdpUrl - .replace(/^ws:/, "http:") - .replace(/^wss:/, "https:") - .replace(/\/devtools\/browser\/.*$/, "") - .replace(/\/cdp$/, "") - .replace(/\/$/, ""); - } -} - function cdpSocketNeedsAttach(wsUrl: string): boolean { try { const pathname = new URL(wsUrl).pathname; diff --git a/src/browser/server-context.loopback-direct-ws.test.ts b/src/browser/server-context.loopback-direct-ws.test.ts new file mode 100644 index 0000000000000..9f6512fab4eaa --- /dev/null +++ b/src/browser/server-context.loopback-direct-ws.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import * as cdpModule from "./cdp.js"; +import { createBrowserRouteContext } from "./server-context.js"; +import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js"; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("browser server-context loopback direct WebSocket profiles", () => { + it("uses an HTTP /json/list base when opening tabs", async () => { + const createTargetViaCdp = vi + .spyOn(cdpModule, "createTargetViaCdp") + .mockResolvedValue({ targetId: "CREATED" }); + + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + expect(u).toBe("http://127.0.0.1:18800/json/list?token=abc"); + return { + ok: true, + json: async () => [ + { + id: "CREATED", + title: "New Tab", + url: "http://127.0.0.1:8080", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", + type: "page", + }, + ], + } as unknown as Response; + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.profiles.openclaw = { + cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + const opened = await openclaw.openTab("http://127.0.0.1:8080"); + expect(opened.targetId).toBe("CREATED"); + expect(createTargetViaCdp).toHaveBeenCalledWith({ + cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", + url: "http://127.0.0.1:8080", + ssrfPolicy: { allowPrivateNetwork: true }, + }); + }); + + it("uses an HTTP /json base for focus and close", async () => { + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + if (u === "http://127.0.0.1:18800/json/list?token=abc") { + return { + ok: true, + json: async () => [ + { + id: "T1", + title: "Tab 1", + url: "https://example.com", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/T1", + type: "page", + }, + ], + } as unknown as Response; + } + if (u === "http://127.0.0.1:18800/json/activate/T1?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + if (u === "http://127.0.0.1:18800/json/close/T1?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + throw new Error(`unexpected fetch: ${u}`); + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.profiles.openclaw = { + cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + await openclaw.focusTab("T1"); + await openclaw.closeTab("T1"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:18800/json/activate/T1?token=abc", + expect.any(Object), + ); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:18800/json/close/T1?token=abc", + expect.any(Object), + ); + }); +}); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index e1c78426eab69..740a99db2b8ef 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -1,4 +1,4 @@ -import { fetchOk } from "./cdp.helpers.js"; +import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath } from "./cdp.js"; import type { ResolvedBrowserProfile } from "./config.js"; import type { PwAiModule } from "./pw-ai-module.js"; @@ -27,6 +27,8 @@ export function createProfileSelectionOps({ listTabs, openTab, }: SelectionDeps): SelectionOps { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); @@ -122,7 +124,7 @@ export function createProfileSelectionOps({ } } - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`)); + await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`)); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; }; @@ -144,7 +146,7 @@ export function createProfileSelectionOps({ } } - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`)); + await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`)); }; return { diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index cf026d658a7e5..fcf0d66ebceeb 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -1,5 +1,5 @@ import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { fetchJson, fetchOk } from "./cdp.helpers.js"; +import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { @@ -58,6 +58,8 @@ export function createProfileTabOps({ state, getProfileState, }: TabOpsDeps): ProfileTabOps { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const listTabs = async (): Promise => { // For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions if (!profile.cdpIsLoopback) { @@ -82,7 +84,7 @@ export function createProfileTabOps({ webSocketDebuggerUrl?: string; type?: string; }> - >(appendCdpPath(profile.cdpUrl, "/json/list")); + >(appendCdpPath(cdpHttpBase, "/json/list")); return raw .map((t) => ({ targetId: t.id ?? "", @@ -115,7 +117,7 @@ export function createProfileTabOps({ const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; for (const tab of candidates.slice(0, excessCount)) { - void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { + void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => { // best-effort cleanup only }); } @@ -180,7 +182,7 @@ export function createProfileTabOps({ } const encoded = encodeURIComponent(url); - const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new")); + const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new")); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts }); const endpoint = endpointUrl.search ? (() => { From 4bfa800cc7c2ab510f5df23dcb84c384bf5539a8 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Sun, 8 Mar 2026 11:56:01 -0700 Subject: [PATCH 144/311] fix: share context engine registry across bundled chunks (#40115) Merged via squash. Prepared head SHA: 6af4820b7d0ea64d96f2f894ef3b0e5750b776aa Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/context-engine/context-engine.test.ts | 13 +++++++++++ src/context-engine/registry.ts | 28 +++++++++++++++++++---- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 904126a13ae1c..def915d48b8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. +- Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. ## 2026.3.7 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index e6788d2f86cdd..91b9ffac52453 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -198,6 +198,19 @@ describe("Registry tests", () => { expect(getContextEngineFactory("reg-overwrite")).toBe(factory2); expect(getContextEngineFactory("reg-overwrite")).not.toBe(factory1); }); + + it("shares registered engines across duplicate module copies", async () => { + const registryUrl = new URL("./registry.ts", import.meta.url).href; + const suffix = Date.now().toString(36); + const first = await import(/* @vite-ignore */ `${registryUrl}?copy=${suffix}-a`); + const second = await import(/* @vite-ignore */ `${registryUrl}?copy=${suffix}-b`); + + const engineId = `dup-copy-${suffix}`; + const factory = () => new MockContextEngine(); + first.registerContextEngine(engineId, factory); + + expect(second.getContextEngineFactory(engineId)).toBe(factory); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 49bf34bfbb330..d73266c62de4e 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -12,27 +12,45 @@ export type ContextEngineFactory = () => ContextEngine | Promise; // Registry (module-level singleton) // --------------------------------------------------------------------------- -const _engines = new Map(); +const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState"); + +type ContextEngineRegistryState = { + engines: Map; +}; + +// Keep context-engine registrations process-global so duplicated dist chunks +// still share one registry map at runtime. +function getContextEngineRegistryState(): ContextEngineRegistryState { + const globalState = globalThis as typeof globalThis & { + [CONTEXT_ENGINE_REGISTRY_STATE]?: ContextEngineRegistryState; + }; + if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) { + globalState[CONTEXT_ENGINE_REGISTRY_STATE] = { + engines: new Map(), + }; + } + return globalState[CONTEXT_ENGINE_REGISTRY_STATE]; +} /** * Register a context engine implementation under the given id. */ export function registerContextEngine(id: string, factory: ContextEngineFactory): void { - _engines.set(id, factory); + getContextEngineRegistryState().engines.set(id, factory); } /** * Return the factory for a registered engine, or undefined. */ export function getContextEngineFactory(id: string): ContextEngineFactory | undefined { - return _engines.get(id); + return getContextEngineRegistryState().engines.get(id); } /** * List all registered engine ids. */ export function listContextEngineIds(): string[] { - return [..._engines.keys()]; + return [...getContextEngineRegistryState().engines.keys()]; } // --------------------------------------------------------------------------- @@ -55,7 +73,7 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise Date: Sun, 15 Feb 2026 22:59:50 -0600 Subject: [PATCH 145/311] fix(browser): rewrite 0.0.0.0 and [::] wildcard addresses in CDP WebSocket URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containerized browsers (e.g. browserless in Docker) report `ws://0.0.0.0:` in their `/json/version` response. `normalizeCdpWsUrl` rewrites loopback WS hosts to the external CDP host:port, but `0.0.0.0` and `[::]` were not treated as addresses needing rewriting, causing OpenClaw to try connecting to `ws://0.0.0.0:3000` literally — which always fails. Fixes #17752 Co-Authored-By: Claude Opus 4.6 --- src/browser/cdp.test.ts | 16 ++++++++++++++++ src/browser/cdp.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 9976869b9dd64..44dbdd70e3073 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -320,6 +320,22 @@ describe("cdp", () => { expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc"); }); + it("rewrites 0.0.0.0 wildcard bind address to remote CDP host", () => { + const normalized = normalizeCdpWsUrl( + "ws://0.0.0.0:3000/devtools/browser/ABC", + "http://192.168.1.202:18850?token=secret", + ); + expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC?token=secret"); + }); + + it("rewrites :: wildcard bind address to remote CDP host", () => { + const normalized = normalizeCdpWsUrl( + "ws://[::]:3000/devtools/browser/ABC", + "http://192.168.1.202:18850", + ); + expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC"); + }); + it("upgrades ws to wss when CDP uses https", () => { const normalized = normalizeCdpWsUrl( "ws://production-sfo.browserless.io", diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 1f078bb4a042d..d8b9994089b26 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -19,7 +19,11 @@ export { export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string { const ws = new URL(wsUrl); const cdp = new URL(cdpUrl); - if (isLoopbackHost(ws.hostname) && !isLoopbackHost(cdp.hostname)) { + // Treat 0.0.0.0 and :: as wildcard bind addresses that need rewriting. + // Containerized browsers (e.g. browserless) report ws://0.0.0.0: + // in /json/version — these must be rewritten to the external cdpUrl host:port. + const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]"; + if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) { ws.hostname = cdp.hostname; const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80"); if (cdpPort) { From dcdce83da75d7ee891f4b11c2c977a2960c28cb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:06:53 +0000 Subject: [PATCH 146/311] fix: normalize wildcard remote CDP websocket URLs (#17760) (thanks @joeharouni) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index def915d48b8d9..b44611a765d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. +- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. ## 2026.3.7 From bcb0d1b8b4ff7ad78911c1d72e504c2fd6ccc02c Mon Sep 17 00:00:00 2001 From: AaronWander Date: Tue, 3 Mar 2026 11:03:49 +0800 Subject: [PATCH 147/311] fix(browser): wait for extension tabs after relay drop (#32331) --- ...-tab-available.prefers-last-target.test.ts | 29 +++++++++++++++++++ src/browser/server-context.selection.ts | 25 ++++++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 81f71cc21d3f4..0f6a5e99a9f7b 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -122,4 +122,33 @@ describe("browser server-context ensureTabAvailable", () => { const chrome = ctx.forProfile("chrome"); await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); }); + + it("waits briefly for extension tabs to reappear when a previous target exists", async () => { + vi.useFakeTimers(); + try { + const responses = [ + // First call: select tab A and store lastTargetId. + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + // Second call: transient drop, then the extension re-announces attached tab A. + [], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + ]; + stubChromeJsonList(responses); + const state = makeBrowserState(); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + const first = await chrome.ensureTabAvailable(); + expect(first.targetId).toBe("A"); + + const secondPromise = chrome.ensureTabAvailable(); + await vi.advanceTimersByTimeAsync(250); + const second = await secondPromise; + expect(second.targetId).toBe("A"); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 740a99db2b8ef..3b0a267f2eb60 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -32,15 +32,28 @@ export function createProfileSelectionOps({ const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); - const tabs1 = await listTabs(); + let tabs1 = await listTabs(); if (tabs1.length === 0) { if (profile.driver === "extension") { - throw new Error( - `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + - "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", - ); + // Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker + // lifecycle, relay restart). If we previously had a target selected, wait briefly for + // the extension to reconnect and re-announce its attached tabs before failing. + if (profileState.lastTargetId?.trim()) { + const deadlineAt = Date.now() + 3_000; + while (tabs1.length === 0 && Date.now() < deadlineAt) { + await new Promise((resolve) => setTimeout(resolve, 200)); + tabs1 = await listTabs(); + } + } + if (tabs1.length === 0) { + throw new Error( + `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + + "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", + ); + } + } else { + await openTab("about:blank"); } - await openTab("about:blank"); } const tabs = await listTabs(); From 0692f71c6f97dfba884c63ca27c46f0cba9b6d68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:11:33 +0000 Subject: [PATCH 148/311] fix: wait for extension relay tab reconnects (#32461) (thanks @AaronWander) --- CHANGELOG.md | 1 + ...-tab-available.prefers-last-target.test.ts | 25 +++++++++++++++++++ src/infra/git-commit.test.ts | 16 +++++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b44611a765d9f..25add7a3d713a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. +- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. ## 2026.3.7 diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 0f6a5e99a9f7b..10de9ee18bcdd 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -151,4 +151,29 @@ describe("browser server-context ensureTabAvailable", () => { vi.useRealTimers(); } }); + + it("still fails after the extension-tab grace window expires", async () => { + vi.useFakeTimers(); + try { + const responses = [ + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + ...Array.from({ length: 20 }, () => []), + ]; + stubChromeJsonList(responses); + const state = makeBrowserState(); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + await chrome.ensureTabAvailable(); + + const pending = expect(chrome.ensureTabAvailable()).rejects.toThrow( + /no attached Chrome tabs/i, + ); + await vi.advanceTimersByTimeAsync(3_500); + await pending; + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index be89c5444b431..26be4322ad865 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -65,7 +65,9 @@ describe("git commit resolution", () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); const temp = await makeTempDir("git-commit-cwd"); const otherRepo = path.join(temp, "other"); @@ -81,7 +83,9 @@ describe("git commit resolution", () => { const otherHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: otherRepo, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); process.chdir(otherRepo); const { resolveCommitHash } = await import("./git-commit.js"); @@ -95,7 +99,9 @@ describe("git commit resolution", () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); const { resolveCommitHash } = await import("./git-commit.js"); const entryModuleUrl = pathToFileURL(path.join(repoRoot, "src", "entry.ts")).href; @@ -161,7 +167,9 @@ describe("git commit resolution", () => { const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { cwd: repoRoot, encoding: "utf-8", - }).trim(); + }) + .trim() + .slice(0, 7); const { resolveCommitHash } = await import("./git-commit.js"); From 436ae8a07c8b8c49ddb215bcdc34aa4cb9632a25 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 7 Mar 2026 18:00:58 -0800 Subject: [PATCH 149/311] fix(infra): make browser relay bind address configurable Add browser.relayBindHost config option so the Chrome extension relay server can bind to a non-loopback address (e.g. 0.0.0.0 for WSL2). Defaults to 127.0.0.1 when unset, preserving current behavior. Closes #39214 Co-Authored-By: Claude Opus 4.6 --- src/browser/config.ts | 3 ++ src/browser/extension-relay.test.ts | 32 ++++++++++++++++++++++ src/browser/extension-relay.ts | 4 ++- src/browser/server-context.availability.ts | 5 +++- src/browser/server-lifecycle.ts | 5 +++- src/config/types.browser.ts | 6 ++++ src/config/zod-schema.ts | 1 + 7 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/browser/config.ts b/src/browser/config.ts index f6b6e1b6d014a..6d24a07a28758 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -36,6 +36,7 @@ export type ResolvedBrowserConfig = { profiles: Record; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; + relayBindHost?: string; }; export type ResolvedBrowserProfile = { @@ -291,6 +292,7 @@ export function resolveBrowserConfig( ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) : []; const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); + const relayBindHost = cfg?.relayBindHost?.trim() || undefined; return { enabled, @@ -312,6 +314,7 @@ export function resolveBrowserConfig( profiles, ssrfPolicy, extraArgs, + relayBindHost, }; } diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index b1478feabd4b7..7208d0dd10bf2 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1168,4 +1168,36 @@ describe("chrome extension relay server", () => { ); await new Promise((resolve) => blocker.close(() => resolve())); }); + + it( + "respects bindHost override to bind on a non-loopback address", + async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + const relay = await ensureChromeExtensionRelayServer({ + cdpUrl, + bindHost: "0.0.0.0", + }); + expect(relay.port).toBe(port); + + // Relay should be reachable on loopback (0.0.0.0 accepts all interfaces). + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + }, + RELAY_TEST_TIMEOUT_MS, + ); + + it( + "defaults bindHost to cdpUrl host when not specified", + async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); + expect(relay.host).toBe("127.0.0.1"); + + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + }, + RELAY_TEST_TIMEOUT_MS, + ); }); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 126bfc8f6824c..2107fff4a32be 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -223,11 +223,13 @@ export function getChromeExtensionRelayAuthHeaders(url: string): Record { const info = parseBaseUrl(opts.cdpUrl); if (!isLoopbackHost(info.host)) { throw new Error(`extension relay requires loopback cdpUrl host (got ${info.host})`); } + const bindHost = opts.bindHost ?? info.host; const existing = relayRuntimeByPort.get(info.port); if (existing) { @@ -962,7 +964,7 @@ export async function ensureChromeExtensionRelayServer(opts: { try { await new Promise((resolve, reject) => { - server.listen(info.port, info.host, () => resolve()); + server.listen(info.port, bindHost, () => resolve()); server.once("error", reject); }); } catch (err) { diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index 47865903b96fe..07772c6b5981d 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -117,7 +117,10 @@ export function createProfileAvailability({ if (isExtension) { if (!httpReachable) { - await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }); + await ensureChromeExtensionRelayServer({ + cdpUrl: profile.cdpUrl, + bindHost: current.resolved.relayBindHost, + }); if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { throw new Error( `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts index 64d10cb7b9fb2..10a4569095a06 100644 --- a/src/browser/server-lifecycle.ts +++ b/src/browser/server-lifecycle.ts @@ -16,7 +16,10 @@ export async function ensureExtensionRelayForProfiles(params: { if (!profile || profile.driver !== "extension") { continue; } - await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => { + await ensureChromeExtensionRelayServer({ + cdpUrl: profile.cdpUrl, + bindHost: params.resolved.relayBindHost, + }).catch((err) => { params.onWarn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`); }); } diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 192fd700bfff9..57d036bd88c0c 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -66,4 +66,10 @@ export type BrowserConfig = { * Example: ["--window-size=1920,1080", "--disable-infobars"] */ extraArgs?: string[]; + /** + * Bind address for the Chrome extension relay server. + * Default: "127.0.0.1". Set to "0.0.0.0" for WSL2 or other environments where + * the relay must be reachable from a different network namespace. + */ + relayBindHost?: string; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 62b7f2f1513db..de4a503cdbffb 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -372,6 +372,7 @@ export const OpenClawSchema = z ) .optional(), extraArgs: z.array(z.string()).optional(), + relayBindHost: z.string().optional(), }) .strict() .optional(), From e883d0b556b069d216e76fedbb17d4b59babd35d Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Sat, 7 Mar 2026 18:22:50 -0800 Subject: [PATCH 150/311] fix(browser): add IP validation, fix upgrade handler for non-loopback bind - Zod schema: validate relayBindHost with ipv4/ipv6 instead of bare string - Upgrade handler: allow non-loopback connections when bindHost is explicitly non-loopback (e.g. 0.0.0.0 for WSL2), keeping loopback-only default - Test: verify actual bind address via relay.bindHost instead of just checking reachability on 127.0.0.1 which passes regardless - Expose bindHost on ChromeExtensionRelayServer type for inspection Co-Authored-By: Claude Opus 4.6 --- src/browser/extension-relay.test.ts | 4 +++- src/browser/extension-relay.ts | 7 ++++++- src/config/zod-schema.ts | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 7208d0dd10bf2..b95dea6c9f265 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1179,8 +1179,9 @@ describe("chrome extension relay server", () => { bindHost: "0.0.0.0", }); expect(relay.port).toBe(port); + // Verify the server actually bound to 0.0.0.0, not the cdpUrl host. + expect(relay.bindHost).toBe("0.0.0.0"); - // Relay should be reachable on loopback (0.0.0.0 accepts all interfaces). const res = await fetch(`http://127.0.0.1:${port}/`); expect(res.status).toBe(200); }, @@ -1194,6 +1195,7 @@ describe("chrome extension relay server", () => { cdpUrl = `http://127.0.0.1:${port}`; const relay = await ensureChromeExtensionRelayServer({ cdpUrl }); expect(relay.host).toBe("127.0.0.1"); + expect(relay.bindHost).toBe("127.0.0.1"); const res = await fetch(`http://127.0.0.1:${port}/`); expect(res.status).toBe(200); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 2107fff4a32be..603ae579c1240 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -113,6 +113,7 @@ function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | export type ChromeExtensionRelayServer = { host: string; + bindHost: string; port: number; baseUrl: string; cdpWsUrl: string; @@ -684,7 +685,9 @@ export async function ensureChromeExtensionRelayServer(opts: { const pathname = url.pathname; const remote = req.socket.remoteAddress; - if (!isLoopbackAddress(remote)) { + // When bindHost is explicitly non-loopback (e.g. 0.0.0.0 for WSL2), + // allow non-loopback connections; otherwise enforce loopback-only. + if (!isLoopbackAddress(remote) && isLoopbackHost(bindHost)) { rejectUpgrade(socket, 403, "Forbidden"); return; } @@ -978,6 +981,7 @@ export async function ensureChromeExtensionRelayServer(opts: { ) { const existingRelay: ChromeExtensionRelayServer = { host: info.host, + bindHost, port: info.port, baseUrl: info.baseUrl, cdpWsUrl: `ws://${info.host}:${info.port}/cdp`, @@ -999,6 +1003,7 @@ export async function ensureChromeExtensionRelayServer(opts: { const relay: ChromeExtensionRelayServer = { host, + bindHost, port, baseUrl, cdpWsUrl: `ws://${host}:${port}/cdp`, diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index de4a503cdbffb..c35d1191b6fdf 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -372,7 +372,7 @@ export const OpenClawSchema = z ) .optional(), extraArgs: z.array(z.string()).optional(), - relayBindHost: z.string().optional(), + relayBindHost: z.union([z.string().ipv4(), z.string().ipv6()]).optional(), }) .strict() .optional(), From d3111fbbcbc4b985b6b3a931a12124798c813602 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:14:59 +0000 Subject: [PATCH 151/311] fix: make browser relay bind address configurable (#39364) (thanks @mvanhorn) --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 2 ++ docs/tools/browser.md | 13 +++++++++++++ docs/tools/chrome-extension.md | 1 + src/browser/extension-relay.test.ts | 19 +++++++++++++++++++ src/browser/extension-relay.ts | 15 ++++++++++++--- src/config/schema.help.ts | 2 ++ src/config/schema.labels.ts | 1 + 8 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25add7a3d713a..0e1c7c6f48d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. - Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. +- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. ## 2026.3.7 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ca6a368141055..538b80f61385d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2354,6 +2354,7 @@ See [Plugins](/tools/plugin). // headless: false, // noSandbox: false, // extraArgs: [], + // relayBindHost: "0.0.0.0", // only when the extension relay must be reachable across namespaces (for example WSL2) // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, }, @@ -2370,6 +2371,7 @@ See [Plugins](/tools/plugin). - Control service: loopback only (port derived from `gateway.port`, default `18791`). - `extraArgs` appends extra launch flags to local Chromium startup (for example `--disable-gpu`, window sizing, or debug flags). +- `relayBindHost` changes where the Chrome extension relay listens. Leave unset for loopback-only access; set an explicit non-loopback bind address such as `0.0.0.0` only when the relay must cross a namespace boundary (for example WSL2) and the host network is already trusted. --- diff --git a/docs/tools/browser.md b/docs/tools/browser.md index e1372a08b9d00..633b2633a33d1 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -328,6 +328,19 @@ Notes: - This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). - Detach by clicking the extension icon again. +- Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated. + +WSL2 / cross-namespace example: + +```json5 +{ + browser: { + enabled: true, + relayBindHost: "0.0.0.0", + defaultProfile: "chrome", + }, +} +``` ## Isolation guarantees diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 964eb40f37b5f..ce4b271ae9ce1 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -161,6 +161,7 @@ Debugging: `openclaw sandbox explain` - Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet. - Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`). +- Leave the relay on loopback unless you have a real cross-namespace need. For WSL2 or similar split-host setups, set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0`, then keep access constrained with Gateway auth, node pairing, and a private network. ## How “extension path” works diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index b95dea6c9f265..f6e14ee8803ee 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1202,4 +1202,23 @@ describe("chrome extension relay server", () => { }, RELAY_TEST_TIMEOUT_MS, ); + + it( + "restarts the relay when bindHost changes for the same port", + async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + + const initial = await ensureChromeExtensionRelayServer({ cdpUrl }); + expect(initial.bindHost).toBe("127.0.0.1"); + + const rebound = await ensureChromeExtensionRelayServer({ + cdpUrl, + bindHost: "0.0.0.0", + }); + expect(rebound.bindHost).toBe("0.0.0.0"); + expect(rebound.port).toBe(port); + }, + RELAY_TEST_TIMEOUT_MS, + ); }); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 603ae579c1240..5a87670605eab 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -234,12 +234,20 @@ export async function ensureChromeExtensionRelayServer(opts: { const existing = relayRuntimeByPort.get(info.port); if (existing) { - return existing.server; + if (existing.server.bindHost !== bindHost) { + await existing.server.stop(); + } else { + return existing.server; + } } const inFlight = relayInitByPort.get(info.port); if (inFlight) { - return await inFlight; + const server = await inFlight; + if (server.bindHost === bindHost) { + return server; + } + await server.stop(); } const extensionReconnectGraceMs = envMsOrDefault( @@ -998,12 +1006,13 @@ export async function ensureChromeExtensionRelayServer(opts: { const addr = server.address() as AddressInfo | null; const port = addr?.port ?? info.port; + const actualBindHost = addr?.address || bindHost; const host = info.host; const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`; const relay: ChromeExtensionRelayServer = { host, - bindHost, + bindHost: actualBindHost, port, baseUrl, cdpWsUrl: `ws://${host}:${port}/cdp`, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 50d9502ffeab8..ec02d1d106fe5 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -250,6 +250,8 @@ export const FIELD_HELP: Record = { "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "browser.defaultProfile": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", + "browser.relayBindHost": + "Bind IP address for the Chrome extension relay listener. Leave unset for loopback-only access, or set an explicit non-loopback IP such as 0.0.0.0 only when the relay must be reachable across network namespaces (for example WSL2) and the surrounding network is already trusted.", "browser.profiles": "Named browser profile connection map used for explicit routing to CDP ports or URLs with optional metadata. Keep profile names consistent and avoid overlapping endpoint definitions.", "browser.profiles.*.cdpPort": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index f8961d9e8dda4..ec9e8eb0c52b7 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -118,6 +118,7 @@ export const FIELD_LABELS: Record = { "browser.attachOnly": "Browser Attach-only Mode", "browser.cdpPortRangeStart": "Browser CDP Port Range Start", "browser.defaultProfile": "Browser Default Profile", + "browser.relayBindHost": "Browser Relay Bind Address", "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", From 9d467d1620d2e89a3a50bb17e071aa9eecc1e559 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:19:14 +0000 Subject: [PATCH 152/311] docs: add WSL2 + Windows remote Chrome CDP troubleshooting (#39407) (thanks @Owlock) --- CHANGELOG.md | 1 + docs/docs.json | 3 +- docs/help/troubleshooting.md | 1 + ...wsl2-windows-remote-cdp-troubleshooting.md | 242 ++++++++++++++++++ docs/tools/browser.md | 3 + docs/tools/index.md | 3 + 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1c7c6f48d9e..243a379e2b7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. - Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. - Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn. +- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. ## 2026.3.7 diff --git a/docs/docs.json b/docs/docs.json index 35e2f37a4a724..8592618cd7d81 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1013,7 +1013,8 @@ "tools/browser", "tools/browser-login", "tools/chrome-extension", - "tools/browser-linux-troubleshooting" + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" ] }, { diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index c2cb1a4312bbd..e051f77f5894f 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -290,6 +290,7 @@ flowchart TD - [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails) - [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting) + - [/tools/browser-wsl2-windows-remote-cdp-troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) - [/tools/chrome-extension](/tools/chrome-extension) diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md new file mode 100644 index 0000000000000..d63bb891c480e --- /dev/null +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -0,0 +1,242 @@ +--- +summary: "Troubleshoot WSL2 Gateway + Windows Chrome remote CDP and extension-relay setups in layers" +read_when: + - Running OpenClaw Gateway in WSL2 while Chrome lives on Windows + - Seeing overlapping browser/control-ui errors across WSL2 and Windows + - Deciding between raw remote CDP and the Chrome extension relay in split-host setups +title: "WSL2 + Windows + remote Chrome CDP troubleshooting" +--- + +# WSL2 + Windows + remote Chrome CDP troubleshooting + +This guide covers the common split-host setup where: + +- OpenClaw Gateway runs inside WSL2 +- Chrome runs on Windows +- browser control must cross the WSL2/Windows boundary + +It also covers the layered failure pattern from [issue #39369](https://github.com/openclaw/openclaw/issues/39369): several independent problems can show up at once, which makes the wrong layer look broken first. + +## Choose the right browser mode first + +You have two valid patterns: + +### Option 1: Raw remote CDP + +Use a remote browser profile that points from WSL2 to a Windows Chrome CDP endpoint. + +Choose this when: + +- you only need browser control +- you are comfortable exposing Chrome remote debugging to WSL2 +- you do not need the Chrome extension relay + +### Option 2: Chrome extension relay + +Use the built-in `chrome` profile plus the OpenClaw Chrome extension. + +Choose this when: + +- you want to attach to an existing Windows Chrome tab with the toolbar button +- you want extension-based control instead of raw `--remote-debugging-port` +- the relay itself must be reachable across the WSL2/Windows boundary + +If you use the extension relay across namespaces, `browser.relayBindHost` is the important setting introduced in [Browser](/tools/browser) and [Chrome extension](/tools/chrome-extension). + +## Working architecture + +Reference shape: + +- WSL2 runs the Gateway on `127.0.0.1:18789` +- Windows opens the Control UI in a normal browser at `http://127.0.0.1:18789/` +- Windows Chrome exposes a CDP endpoint on port `9222` +- WSL2 can reach that Windows CDP endpoint +- OpenClaw points a browser profile at the address that is reachable from WSL2 + +## Why this setup is confusing + +Several failures can overlap: + +- WSL2 cannot reach the Windows CDP endpoint +- the Control UI is opened from a non-secure origin +- `gateway.controlUi.allowedOrigins` does not match the page origin +- token or pairing is missing +- the browser profile points at the wrong address +- the extension relay is still loopback-only when you actually need cross-namespace access + +Because of that, fixing one layer can still leave a different error visible. + +## Critical rule for the Control UI + +When the UI is opened from Windows, use Windows localhost unless you have a deliberate HTTPS setup. + +Use: + +`http://127.0.0.1:18789/` + +Do not default to a LAN IP for the Control UI. Plain HTTP on a LAN or tailnet address can trigger insecure-origin/device-auth behavior that is unrelated to CDP itself. See [Control UI](/web/control-ui). + +## Validate in layers + +Work top to bottom. Do not skip ahead. + +### Layer 1: Verify Chrome is serving CDP on Windows + +Start Chrome on Windows with remote debugging enabled: + +```powershell +chrome.exe --remote-debugging-port=9222 +``` + +From Windows, verify Chrome itself first: + +```powershell +curl http://127.0.0.1:9222/json/version +curl http://127.0.0.1:9222/json/list +``` + +If this fails on Windows, OpenClaw is not the problem yet. + +### Layer 2: Verify WSL2 can reach that Windows endpoint + +From WSL2, test the exact address you plan to use in `cdpUrl`: + +```bash +curl http://WINDOWS_HOST_OR_IP:9222/json/version +curl http://WINDOWS_HOST_OR_IP:9222/json/list +``` + +Good result: + +- `/json/version` returns JSON with Browser / Protocol-Version metadata +- `/json/list` returns JSON (empty array is fine if no pages are open) + +If this fails: + +- Windows is not exposing the port to WSL2 yet +- the address is wrong for the WSL2 side +- firewall / port forwarding / local proxying is still missing + +Fix that before touching OpenClaw config. + +### Layer 3: Configure the correct browser profile + +For raw remote CDP, point OpenClaw at the address that is reachable from WSL2: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "remote", + profiles: { + remote: { + cdpUrl: "http://WINDOWS_HOST_OR_IP:9222", + attachOnly: true, + color: "#00AA00", + }, + }, + }, +} +``` + +Notes: + +- use the WSL2-reachable address, not whatever only works on Windows +- keep `attachOnly: true` for externally managed browsers +- test the same URL with `curl` before expecting OpenClaw to succeed + +### Layer 4: If you use the Chrome extension relay instead + +If the browser machine and the Gateway are separated by a namespace boundary, the relay may need a non-loopback bind address. + +Example: + +```json5 +{ + browser: { + enabled: true, + defaultProfile: "chrome", + relayBindHost: "0.0.0.0", + }, +} +``` + +Use this only when needed: + +- default behavior is safer because the relay stays loopback-only +- `0.0.0.0` expands exposure surface +- keep Gateway auth, node pairing, and the surrounding network private + +If you do not need the extension relay, prefer the raw remote CDP profile above. + +### Layer 5: Verify the Control UI layer separately + +Open the UI from Windows: + +`http://127.0.0.1:18789/` + +Then verify: + +- the page origin matches what `gateway.controlUi.allowedOrigins` expects +- token auth or pairing is configured correctly +- you are not debugging a Control UI auth problem as if it were a browser problem + +Helpful page: + +- [Control UI](/web/control-ui) + +### Layer 6: Verify end-to-end browser control + +From WSL2: + +```bash +openclaw browser open https://example.com --browser-profile remote +openclaw browser tabs --browser-profile remote +``` + +For the extension relay: + +```bash +openclaw browser tabs --browser-profile chrome +``` + +Good result: + +- the tab opens in Windows Chrome +- `openclaw browser tabs` returns the target +- later actions (`snapshot`, `screenshot`, `navigate`) work from the same profile + +## Common misleading errors + +Treat each message as a layer-specific clue: + +- `control-ui-insecure-auth` + - UI origin / secure-context problem, not a CDP transport problem +- `token_missing` + - auth configuration problem +- `pairing required` + - device approval problem +- `Remote CDP for profile "remote" is not reachable` + - WSL2 cannot reach the configured `cdpUrl` +- `gateway timeout after 1500ms` + - often still CDP reachability or a slow/unreachable remote endpoint +- `Chrome extension relay is running, but no tab is connected` + - extension relay profile selected, but no attached tab exists yet + +## Fast triage checklist + +1. Windows: does `curl http://127.0.0.1:9222/json/version` work? +2. WSL2: does `curl http://WINDOWS_HOST_OR_IP:9222/json/version` work? +3. OpenClaw config: does `browser.profiles..cdpUrl` use that exact WSL2-reachable address? +4. Control UI: are you opening `http://127.0.0.1:18789/` instead of a LAN IP? +5. Extension relay only: do you actually need `browser.relayBindHost`, and if so is it set explicitly? + +## Practical takeaway + +The setup is usually viable. The hard part is that browser transport, Control UI origin security, token/pairing, and extension-relay topology can each fail independently while looking similar from the user side. + +When in doubt: + +- verify the Windows Chrome endpoint locally first +- verify the same endpoint from WSL2 second +- only then debug OpenClaw config or Control UI auth diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 633b2633a33d1..d632e7130683b 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -649,6 +649,9 @@ Strict-mode example (block private/internal destinations by default): For Linux-specific issues (especially snap Chromium), see [Browser troubleshooting](/tools/browser-linux-troubleshooting). +For WSL2 Gateway + Windows Chrome split-host setups, see +[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting). + ## Agent tools + how control works The agent gets **one tool** for browser automation: diff --git a/docs/tools/index.md b/docs/tools/index.md index 0f311516dcdcf..6552d6f9118b1 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -531,6 +531,9 @@ Browser tool: - `profile` (optional; defaults to `browser.defaultProfile`) - `target` (`sandbox` | `host` | `node`) - `node` (optional; pin a specific node id/name) +- Troubleshooting guides: + - Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting) + - WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting) ## Recommended agent flows From 6b338dd28342b0f07ee068a3356f39cef248170d Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Wed, 4 Mar 2026 09:31:08 -0600 Subject: [PATCH 153/311] macos: add remote gateway token field for remote mode --- apps/macos/Sources/OpenClaw/AppState.swift | 43 ++++++++++++++++++- .../OpenClaw/GatewayRemoteConfig.swift | 11 +++++ .../Sources/OpenClaw/GeneralSettings.swift | 19 ++++++++ .../OpenClaw/OnboardingView+Pages.swift | 8 ++++ .../AppStateRemoteConfigTests.swift | 34 +++++++++++++++ .../GatewayEndpointStoreTests.swift | 17 +++++++- 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index ef4917e7768fe..e670235ddba51 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -213,6 +213,10 @@ final class AppState { didSet { self.syncGatewayConfigIfNeeded() } } + var remoteToken: String { + didSet { self.syncGatewayConfigIfNeeded() } + } + var remoteIdentity: String { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @@ -297,6 +301,7 @@ final class AppState { self.remoteTarget = storedRemoteTarget } self.remoteUrl = configRemoteUrl ?? "" + self.remoteToken = GatewayRemoteConfig.resolveTokenString(root: configRoot) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -380,7 +385,8 @@ final class AppState { remoteUrl: String, remoteHost: String?, remoteTarget: String, - remoteIdentity: String) -> (remote: [String: Any], changed: Bool) + remoteIdentity: String, + remoteToken: String) -> (remote: [String: Any], changed: Bool) { var remote = current var changed = false @@ -417,6 +423,8 @@ final class AppState { changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed } + changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + return (remote, changed) } @@ -439,6 +447,7 @@ final class AppState { let gateway = root["gateway"] as? [String: Any] let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root) + let remoteToken = GatewayRemoteConfig.resolveTokenString(root: root) ?? "" let hasRemoteUrl = !(remoteUrl? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -470,6 +479,9 @@ final class AppState { if remoteUrlText != self.remoteUrl { self.remoteUrl = remoteUrlText } + if remoteToken != self.remoteToken { + self.remoteToken = remoteToken + } let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, @@ -504,6 +516,7 @@ final class AppState { let remoteIdentity = self.remoteIdentity let remoteTransport = self.remoteTransport let remoteUrl = self.remoteUrl + let remoteToken = self.remoteToken let desiredMode: String? = switch connectionMode { case .local: "local" @@ -541,7 +554,8 @@ final class AppState { remoteUrl: remoteUrl, remoteHost: remoteHost, remoteTarget: remoteTarget, - remoteIdentity: remoteIdentity) + remoteIdentity: remoteIdentity, + remoteToken: remoteToken) if updated.changed { gateway["remote"] = updated.remote changed = true @@ -697,6 +711,7 @@ extension AppState { state.canvasEnabled = true state.remoteTarget = "user@example.com" state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteToken = "example-token" state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteProjectRoot = "~/Projects/openclaw" state.remoteCliPath = "" @@ -704,6 +719,30 @@ extension AppState { } } +#if DEBUG +@MainActor +extension AppState { + static func _testUpdatedRemoteGatewayConfig( + current: [String: Any], + transport: RemoteTransport, + remoteUrl: String, + remoteHost: String?, + remoteTarget: String, + remoteIdentity: String, + remoteToken: String) -> [String: Any] + { + Self.updatedRemoteGatewayConfig( + current: current, + transport: transport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteToken: remoteToken).remote + } +} +#endif + @MainActor enum AppStateStore { static let shared = AppState() diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 3d044bcda2ffe..2a9120d8e831c 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -24,6 +24,17 @@ enum GatewayRemoteConfig { return trimmed.isEmpty ? nil : trimmed } + static func resolveTokenString(root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let tokenRaw = remote["token"] as? String + else { + return nil + } + let trimmed = tokenRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + static func resolveGatewayUrl(root: [String: Any]) -> URL? { guard let raw = self.resolveUrlString(root: root) else { return nil } return self.normalizeGatewayUrl(raw) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index bdf02d9499242..1997b5ba58a47 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -149,6 +149,7 @@ struct GeneralSettings: View { } else { self.remoteDirectRow } + self.remoteTokenRow GatewayDiscoveryInlineList( discovery: self.gatewayDiscovery, @@ -291,6 +292,23 @@ struct GeneralSettings: View { } } + private var remoteTokenRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 10) { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: self.remoteLabelWidth, alignment: .leading) + SecureField("token from gateway.auth.token", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + Text("Used when the remote gateway requires token auth.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, self.remoteLabelWidth + 10) + } + } + private func remoteTestButton(disabled: Bool) -> some View { Button { Task { await self.testRemote() } @@ -692,6 +710,7 @@ extension GeneralSettings { state.remoteTransport = .ssh state.remoteTarget = "user@host:2222" state.remoteUrl = "wss://gateway.example.ts.net" + state.remoteToken = "example-token" state.remoteIdentity = "/tmp/id_ed25519" state.remoteProjectRoot = "/tmp/openclaw" state.remoteCliPath = "/tmp/openclaw" diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 41d28b49092b7..2a9224699b797 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -199,6 +199,14 @@ extension OnboardingView { .pickerStyle(.segmented) .frame(width: fieldWidth) } + GridRow { + Text("Gateway token") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + SecureField("token from gateway.auth.token", text: self.$state.remoteToken) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } if self.state.remoteTransport == .direct { GridRow { Text("Gateway URL") diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift new file mode 100644 index 0000000000000..8b903aa2a11fc --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct AppStateRemoteConfigTests { + @Test + func updatedRemoteGatewayConfigSetsTrimmedToken() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: [:], + transport: .ssh, + remoteUrl: "", + remoteHost: "gateway.example", + remoteTarget: "alice@gateway.example", + remoteIdentity: "/tmp/id_ed25519", + remoteToken: " secret-token ") + + #expect(remote["token"] as? String == "secret-token") + } + + @Test + func updatedRemoteGatewayConfigClearsTokenWhenBlank() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: ["token": "old-token"], + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " ") + + #expect((remote["token"] as? String) == nil) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 39ca29b33d526..418780c1a70fc 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -61,7 +61,22 @@ struct GatewayEndpointStoreTests { #expect(token == nil) } - @Test func `resolve gateway password falls back to launchd`() { + @Test func resolveGatewayTokenUsesRemoteConfigToken() { + let token = GatewayEndpointStore._testResolveGatewayToken( + isRemote: true, + root: [ + "gateway": [ + "remote": [ + "token": " remote-token ", + ], + ], + ], + env: [:], + launchdSnapshot: nil) + #expect(token == "remote-token") + } + + @Test func resolveGatewayPasswordFallsBackToLaunchd() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], token: nil, From bd0e6a6efdb6bb6d7cbfc76f3c0b619a381e9eb8 Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Wed, 4 Mar 2026 09:37:55 -0600 Subject: [PATCH 154/311] macos: clarify remote token placeholder text --- apps/macos/Sources/OpenClaw/GeneralSettings.swift | 2 +- apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 1997b5ba58a47..5c2cae2659d5d 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -298,7 +298,7 @@ struct GeneralSettings: View { Text("Gateway token") .font(.callout.weight(.semibold)) .frame(width: self.remoteLabelWidth, alignment: .leading) - SecureField("token from gateway.auth.token", text: self.$state.remoteToken) + SecureField("remote gateway auth token (gateway.auth.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 2a9224699b797..578d3a6ff052c 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -203,7 +203,7 @@ extension OnboardingView { Text("Gateway token") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) - SecureField("token from gateway.auth.token", text: self.$state.remoteToken) + SecureField("remote gateway auth token (gateway.auth.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } From 37e0b016842ef9e2bc02bf1667343c4b14d0c621 Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Wed, 4 Mar 2026 10:13:20 -0600 Subject: [PATCH 155/311] macos: add mode-toggle remote token sync coverage --- apps/macos/Sources/OpenClaw/AppState.swift | 134 ++++++++++++------ .../AppStateRemoteConfigTests.swift | 40 ++++++ 2 files changed, 127 insertions(+), 47 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index e670235ddba51..57d8ae8787890 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -508,15 +508,19 @@ final class AppState { } } - private func syncGatewayConfigIfNeeded() { - guard !self.isPreview, !self.isInitializing else { return } + private static func syncedGatewayRoot( + currentRoot: [String: Any], + connectionMode: ConnectionMode, + remoteTransport: RemoteTransport, + remoteTarget: String, + remoteIdentity: String, + remoteUrl: String, + remoteToken: String) -> (root: [String: Any], changed: Bool) + { + var root = currentRoot + var gateway = root["gateway"] as? [String: Any] ?? [:] + var changed = false - let connectionMode = self.connectionMode - let remoteTarget = self.remoteTarget - let remoteIdentity = self.remoteIdentity - let remoteTransport = self.remoteTransport - let remoteUrl = self.remoteUrl - let remoteToken = self.remoteToken let desiredMode: String? = switch connectionMode { case .local: "local" @@ -525,50 +529,67 @@ final class AppState { case .unconfigured: nil } - let remoteHost = connectionMode == .remote - ? CommandResolver.parseSSHTarget(remoteTarget)?.host - : nil - Task { @MainActor in - // Keep app-only connection settings local to avoid overwriting remote gateway config. - var root = OpenClawConfigFile.loadDict() - var gateway = root["gateway"] as? [String: Any] ?? [:] - var changed = false - - let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - if let desiredMode { - if currentMode != desiredMode { - gateway["mode"] = desiredMode - changed = true - } - } else if currentMode != nil { - gateway.removeValue(forKey: "mode") + let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let desiredMode { + if currentMode != desiredMode { + gateway["mode"] = desiredMode changed = true } - - if connectionMode == .remote { - let currentRemote = gateway["remote"] as? [String: Any] ?? [:] - let updated = Self.updatedRemoteGatewayConfig( - current: currentRemote, - transport: remoteTransport, - remoteUrl: remoteUrl, - remoteHost: remoteHost, - remoteTarget: remoteTarget, - remoteIdentity: remoteIdentity, - remoteToken: remoteToken) - if updated.changed { - gateway["remote"] = updated.remote - changed = true - } + } else if currentMode != nil { + gateway.removeValue(forKey: "mode") + changed = true + } + + if connectionMode == .remote { + let remoteHost = CommandResolver.parseSSHTarget(remoteTarget)?.host + let currentRemote = gateway["remote"] as? [String: Any] ?? [:] + let updated = Self.updatedRemoteGatewayConfig( + current: currentRemote, + transport: remoteTransport, + remoteUrl: remoteUrl, + remoteHost: remoteHost, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteToken: remoteToken) + if updated.changed { + gateway["remote"] = updated.remote + changed = true } + } - guard changed else { return } - if gateway.isEmpty { - root.removeValue(forKey: "gateway") - } else { - root["gateway"] = gateway - } - OpenClawConfigFile.saveDict(root) + guard changed else { return (currentRoot, false) } + + if gateway.isEmpty { + root.removeValue(forKey: "gateway") + } else { + root["gateway"] = gateway + } + return (root, true) + } + + private func syncGatewayConfigIfNeeded() { + guard !self.isPreview, !self.isInitializing else { return } + + let connectionMode = self.connectionMode + let remoteTarget = self.remoteTarget + let remoteIdentity = self.remoteIdentity + let remoteTransport = self.remoteTransport + let remoteUrl = self.remoteUrl + let remoteToken = self.remoteToken + + Task { @MainActor in + // Keep app-only connection settings local to avoid overwriting remote gateway config. + let synced = Self.syncedGatewayRoot( + currentRoot: OpenClawConfigFile.loadDict(), + connectionMode: connectionMode, + remoteTransport: remoteTransport, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteUrl: remoteUrl, + remoteToken: remoteToken) + guard synced.changed else { return } + OpenClawConfigFile.saveDict(synced.root) } } @@ -740,6 +761,25 @@ extension AppState { remoteIdentity: remoteIdentity, remoteToken: remoteToken).remote } + + static func _testSyncedGatewayRoot( + currentRoot: [String: Any], + connectionMode: ConnectionMode, + remoteTransport: RemoteTransport, + remoteTarget: String, + remoteIdentity: String, + remoteUrl: String, + remoteToken: String) -> [String: Any] + { + Self.syncedGatewayRoot( + currentRoot: currentRoot, + connectionMode: connectionMode, + remoteTransport: remoteTransport, + remoteTarget: remoteTarget, + remoteIdentity: remoteIdentity, + remoteUrl: remoteUrl, + remoteToken: remoteToken).root + } } #endif diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index 8b903aa2a11fc..ac6d696ab4ff9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -31,4 +31,44 @@ struct AppStateRemoteConfigTests { #expect((remote["token"] as? String) == nil) } + + @Test + func syncedGatewayRootPreservesTokenAcrossModeToggleAndClearsOnBlankRemoteToken() { + let remoteRoot = AppState._testSyncedGatewayRoot( + currentRoot: [:], + connectionMode: .remote, + remoteTransport: .direct, + remoteTarget: "", + remoteIdentity: "", + remoteUrl: "wss://gateway.example", + remoteToken: " persisted-token ") + let remoteGateway = remoteRoot["gateway"] as? [String: Any] + let remoteConfig = remoteGateway?["remote"] as? [String: Any] + #expect(remoteGateway?["mode"] as? String == "remote") + #expect(remoteConfig?["token"] as? String == "persisted-token") + + let localRoot = AppState._testSyncedGatewayRoot( + currentRoot: remoteRoot, + connectionMode: .local, + remoteTransport: .direct, + remoteTarget: "", + remoteIdentity: "", + remoteUrl: "", + remoteToken: "") + let localGateway = localRoot["gateway"] as? [String: Any] + let localRemoteConfig = localGateway?["remote"] as? [String: Any] + #expect(localGateway?["mode"] as? String == "local") + #expect(localRemoteConfig?["token"] as? String == "persisted-token") + + let clearedRoot = AppState._testSyncedGatewayRoot( + currentRoot: localRoot, + connectionMode: .remote, + remoteTransport: .direct, + remoteTarget: "", + remoteIdentity: "", + remoteUrl: "wss://gateway.example", + remoteToken: " ") + let clearedRemote = (clearedRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] + #expect((clearedRemote?["token"] as? String) == nil) + } } From 3b7a72bffb8ea9e2f9ed80ca42540b20597ccf24 Mon Sep 17 00:00:00 2001 From: Charles Dusek Date: Wed, 4 Mar 2026 10:25:05 -0600 Subject: [PATCH 156/311] tests: document remote token persistence across mode toggle --- .../macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index ac6d696ab4ff9..8dc47ffa1ad18 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -57,6 +57,7 @@ struct AppStateRemoteConfigTests { remoteToken: "") let localGateway = localRoot["gateway"] as? [String: Any] let localRemoteConfig = localGateway?["remote"] as? [String: Any] + // Local mode should not discard remote token state; users can return to remote mode later. #expect(localGateway?["mode"] as? String == "local") #expect(localRemoteConfig?["token"] as? String == "persisted-token") From 3d3e8fe78c911ae1edd6b30717449ab41ef26f62 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 8 Mar 2026 20:30:43 +0200 Subject: [PATCH 157/311] fix(macos): preserve unsupported remote gateway tokens --- CHANGELOG.md | 1 + apps/macos/Sources/OpenClaw/AppState.swift | 65 ++++++++--- .../OpenClaw/GatewayEndpointStore.swift | 8 +- .../OpenClaw/GatewayRemoteConfig.swift | 44 +++++++- .../Sources/OpenClaw/GeneralSettings.swift | 9 +- .../OpenClaw/OnboardingView+Pages.swift | 13 ++- .../AppStateRemoteConfigTests.swift | 105 +++++++++++++----- 7 files changed, 190 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 243a379e2b7c1..4bad6ba3d8d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. - Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. +- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#34614) ### Fixes diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 57d8ae8787890..5e8238ebe92e0 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -9,6 +9,7 @@ import SwiftUI final class AppState { private let isPreview: Bool private var isInitializing = true + private var isApplyingRemoteTokenConfig = false private var configWatcher: ConfigFileWatcher? private var suppressVoiceWakeGlobalSync = false private var voiceWakeGlobalSyncTask: Task? @@ -214,9 +215,17 @@ final class AppState { } var remoteToken: String { - didSet { self.syncGatewayConfigIfNeeded() } + didSet { + guard !self.isApplyingRemoteTokenConfig else { return } + self.remoteTokenDirty = true + self.remoteTokenUnsupported = false + self.syncGatewayConfigIfNeeded() + } } + private(set) var remoteTokenDirty = false + private(set) var remoteTokenUnsupported = false + var remoteIdentity: String { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } } } @@ -285,6 +294,7 @@ final class AppState { let configRoot = OpenClawConfigFile.loadDict() let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot) + let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot) let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot) let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode self.remoteTransport = configRemoteTransport @@ -301,7 +311,9 @@ final class AppState { self.remoteTarget = storedRemoteTarget } self.remoteUrl = configRemoteUrl ?? "" - self.remoteToken = GatewayRemoteConfig.resolveTokenString(root: configRoot) ?? "" + self.remoteToken = configRemoteToken.textFieldValue + self.remoteTokenDirty = false + self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" @@ -379,6 +391,20 @@ final class AppState { return false } + private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) { + let nextToken = tokenValue.textFieldValue + let unsupported = tokenValue.isUnsupportedNonString + guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported + else { + return + } + self.isApplyingRemoteTokenConfig = true + self.remoteToken = nextToken + self.isApplyingRemoteTokenConfig = false + self.remoteTokenDirty = false + self.remoteTokenUnsupported = unsupported + } + private static func updatedRemoteGatewayConfig( current: [String: Any], transport: RemoteTransport, @@ -386,7 +412,8 @@ final class AppState { remoteHost: String?, remoteTarget: String, remoteIdentity: String, - remoteToken: String) -> (remote: [String: Any], changed: Bool) + remoteToken: String, + remoteTokenDirty: Bool) -> (remote: [String: Any], changed: Bool) { var remote = current var changed = false @@ -423,7 +450,9 @@ final class AppState { changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed } - changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + if remoteTokenDirty { + changed = Self.updateGatewayString(&remote, key: "token", value: remoteToken) || changed + } return (remote, changed) } @@ -447,7 +476,7 @@ final class AppState { let gateway = root["gateway"] as? [String: Any] let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root) - let remoteToken = GatewayRemoteConfig.resolveTokenString(root: root) ?? "" + let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root) let hasRemoteUrl = !(remoteUrl? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty ?? true) @@ -479,9 +508,7 @@ final class AppState { if remoteUrlText != self.remoteUrl { self.remoteUrl = remoteUrlText } - if remoteToken != self.remoteToken { - self.remoteToken = remoteToken - } + self.applyRemoteTokenState(remoteToken) let targetMode = desiredMode ?? self.connectionMode if targetMode == .remote, @@ -515,7 +542,8 @@ final class AppState { remoteTarget: String, remoteIdentity: String, remoteUrl: String, - remoteToken: String) -> (root: [String: Any], changed: Bool) + remoteToken: String, + remoteTokenDirty: Bool) -> (root: [String: Any], changed: Bool) { var root = currentRoot var gateway = root["gateway"] as? [String: Any] ?? [:] @@ -551,7 +579,8 @@ final class AppState { remoteHost: remoteHost, remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, - remoteToken: remoteToken) + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty) if updated.changed { gateway["remote"] = updated.remote changed = true @@ -577,6 +606,7 @@ final class AppState { let remoteTransport = self.remoteTransport let remoteUrl = self.remoteUrl let remoteToken = self.remoteToken + let remoteTokenDirty = self.remoteTokenDirty Task { @MainActor in // Keep app-only connection settings local to avoid overwriting remote gateway config. @@ -587,7 +617,8 @@ final class AppState { remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, remoteUrl: remoteUrl, - remoteToken: remoteToken) + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty) guard synced.changed else { return } OpenClawConfigFile.saveDict(synced.root) } @@ -750,7 +781,8 @@ extension AppState { remoteHost: String?, remoteTarget: String, remoteIdentity: String, - remoteToken: String) -> [String: Any] + remoteToken: String, + remoteTokenDirty: Bool) -> [String: Any] { Self.updatedRemoteGatewayConfig( current: current, @@ -759,7 +791,8 @@ extension AppState { remoteHost: remoteHost, remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, - remoteToken: remoteToken).remote + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty).remote } static func _testSyncedGatewayRoot( @@ -769,7 +802,8 @@ extension AppState { remoteTarget: String, remoteIdentity: String, remoteUrl: String, - remoteToken: String) -> [String: Any] + remoteToken: String, + remoteTokenDirty: Bool) -> [String: Any] { Self.syncedGatewayRoot( currentRoot: currentRoot, @@ -778,7 +812,8 @@ extension AppState { remoteTarget: remoteTarget, remoteIdentity: remoteIdentity, remoteUrl: remoteUrl, - remoteToken: remoteToken).root + remoteToken: remoteToken, + remoteTokenDirty: remoteTokenDirty).root } } #endif diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 86fa9828baf98..2d923a5ea9e7c 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -188,13 +188,7 @@ actor GatewayEndpointStore { private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? { if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let token = remote["token"] as? String - { - return token.trimmingCharacters(in: .whitespacesAndNewlines) - } - return nil + return GatewayRemoteConfig.resolveTokenString(root: root) } if let gateway = root["gateway"] as? [String: Any], diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 2a9120d8e831c..4eee8165d528d 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -2,6 +2,28 @@ import Foundation import OpenClawKit enum GatewayRemoteConfig { + enum TokenValue: Equatable { + case missing + case plaintext(String) + case unsupportedNonString + + var textFieldValue: String { + switch self { + case let .plaintext(token): + token + case .missing, .unsupportedNonString: + "" + } + } + + var isUnsupportedNonString: Bool { + if case .unsupportedNonString = self { + return true + } + return false + } + } + static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], @@ -24,15 +46,27 @@ enum GatewayRemoteConfig { return trimmed.isEmpty ? nil : trimmed } - static func resolveTokenString(root: [String: Any]) -> String? { + static func resolveTokenValue(root: [String: Any]) -> TokenValue { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], - let tokenRaw = remote["token"] as? String + let tokenRaw = remote["token"] else { - return nil + return .missing + } + guard let tokenString = tokenRaw as? String else { + return .unsupportedNonString + } + let trimmed = tokenString.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? .missing : .plaintext(trimmed) + } + + static func resolveTokenString(root: [String: Any]) -> String? { + switch self.resolveTokenValue(root: root) { + case let .plaintext(token): + token + case .missing, .unsupportedNonString: + nil } - let trimmed = tokenRaw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed } static func resolveGatewayUrl(root: [String: Any]) -> URL? { diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 5c2cae2659d5d..b55ed439489d9 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -298,7 +298,7 @@ struct GeneralSettings: View { Text("Gateway token") .font(.callout.weight(.semibold)) .frame(width: self.remoteLabelWidth, alignment: .leading) - SecureField("remote gateway auth token (gateway.auth.token)", text: self.$state.remoteToken) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } @@ -306,6 +306,13 @@ struct GeneralSettings: View { .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) + if self.state.remoteTokenUnsupported { + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .padding(.leading, self.remoteLabelWidth + 10) + } } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 578d3a6ff052c..8f4d16420bcf4 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -203,10 +203,21 @@ extension OnboardingView { Text("Gateway token") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) - SecureField("remote gateway auth token (gateway.auth.token)", text: self.$state.remoteToken) + SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } + if self.state.remoteTokenUnsupported { + GridRow { + Text("") + .frame(width: labelWidth, alignment: .leading) + Text( + "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.") + .font(.caption) + .foregroundStyle(.orange) + .frame(width: fieldWidth, alignment: .leading) + } + } if self.state.remoteTransport == .direct { GridRow { Text("Gateway URL") diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index 8dc47ffa1ad18..172dc7ffc552f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -13,7 +13,8 @@ struct AppStateRemoteConfigTests { remoteHost: "gateway.example", remoteTarget: "alice@gateway.example", remoteIdentity: "/tmp/id_ed25519", - remoteToken: " secret-token ") + remoteToken: " secret-token ", + remoteTokenDirty: true) #expect(remote["token"] as? String == "secret-token") } @@ -27,49 +28,101 @@ struct AppStateRemoteConfigTests { remoteHost: nil, remoteTarget: "", remoteIdentity: "", - remoteToken: " ") + remoteToken: " ", + remoteTokenDirty: true) #expect((remote["token"] as? String) == nil) } @Test - func syncedGatewayRootPreservesTokenAcrossModeToggleAndClearsOnBlankRemoteToken() { - let remoteRoot = AppState._testSyncedGatewayRoot( - currentRoot: [:], + func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() { + let initialRoot: [String: Any] = [ + "gateway": [ + "mode": "remote", + "remote": [ + "transport": "direct", + "url": "wss://old-gateway.example", + "token": [ + "$secretRef": "gateway-token", + ], + ], + ], + ] + + let sshRoot = AppState._testSyncedGatewayRoot( + currentRoot: initialRoot, connectionMode: .remote, - remoteTransport: .direct, - remoteTarget: "", + remoteTransport: .ssh, + remoteTarget: "alice@gateway.example", remoteIdentity: "", - remoteUrl: "wss://gateway.example", - remoteToken: " persisted-token ") - let remoteGateway = remoteRoot["gateway"] as? [String: Any] - let remoteConfig = remoteGateway?["remote"] as? [String: Any] - #expect(remoteGateway?["mode"] as? String == "remote") - #expect(remoteConfig?["token"] as? String == "persisted-token") + remoteUrl: "", + remoteToken: "", + remoteTokenDirty: false) + let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] + #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") let localRoot = AppState._testSyncedGatewayRoot( - currentRoot: remoteRoot, + currentRoot: sshRoot, connectionMode: .local, - remoteTransport: .direct, + remoteTransport: .ssh, remoteTarget: "", remoteIdentity: "", remoteUrl: "", - remoteToken: "") + remoteToken: "", + remoteTokenDirty: false) let localGateway = localRoot["gateway"] as? [String: Any] - let localRemoteConfig = localGateway?["remote"] as? [String: Any] - // Local mode should not discard remote token state; users can return to remote mode later. + let localRemote = localGateway?["remote"] as? [String: Any] #expect(localGateway?["mode"] as? String == "local") - #expect(localRemoteConfig?["token"] as? String == "persisted-token") + #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") + } - let clearedRoot = AppState._testSyncedGatewayRoot( - currentRoot: localRoot, - connectionMode: .remote, - remoteTransport: .direct, + @Test + func updatedRemoteGatewayConfigReplacesObjectTokenWhenUserEntersPlaintext() { + let remote = AppState._testUpdatedRemoteGatewayConfig( + current: [ + "token": [ + "$secretRef": "gateway-token", + ], + ], + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, remoteTarget: "", remoteIdentity: "", + remoteToken: " fresh-token ", + remoteTokenDirty: true) + + #expect(remote["token"] as? String == "fresh-token") + } + + @Test + func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() { + let current: [String: Any] = [ + "token": [ + "$secretRef": "gateway-token", + ], + ] + + let preserved = AppState._testUpdatedRemoteGatewayConfig( + current: current, + transport: .direct, remoteUrl: "wss://gateway.example", - remoteToken: " ") - let clearedRemote = (clearedRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] - #expect((clearedRemote?["token"] as? String) == nil) + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: "", + remoteTokenDirty: false) + #expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") + + let cleared = AppState._testUpdatedRemoteGatewayConfig( + current: current, + transport: .direct, + remoteUrl: "wss://gateway.example", + remoteHost: nil, + remoteTarget: "", + remoteIdentity: "", + remoteToken: " ", + remoteTokenDirty: true) + #expect((cleared["token"] as? String) == nil) } } From 92726d9863b9ddb729ce8861d628cce6f371b595 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 8 Mar 2026 21:16:37 +0200 Subject: [PATCH 158/311] docs(changelog): credit macos remote token author --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bad6ba3d8d9e..fd8375f0e8824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. - Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. -- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#34614) +- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. ### Fixes From a6131438eaa003e069f7f54c9bfc7b174a53f402 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 8 Mar 2026 21:49:42 +0200 Subject: [PATCH 159/311] fix(macos): improve tailscale gateway discovery (#40167) Sanitized test tailnet hostnames and re-ran the targeted macOS gateway discovery test suite before merge. --- CHANGELOG.md | 1 + .../GatewayDiscoverySelectionSupport.swift | 39 +++++++- .../GatewayDiscoveryModel.swift | 20 +++-- .../TailscaleServeGatewayDiscovery.swift | 14 +++ .../GatewayDiscoveryModelTests.swift | 50 +++++++++++ ...atewayDiscoverySelectionSupportTests.swift | 90 +++++++++++++++++++ .../TailscaleServeGatewayDiscoveryTests.swift | 21 +++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8375f0e8824..deff9b4b06ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn. - Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. +- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. ## 2026.3.7 diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift index ea7492b2c79f4..99bb654526b64 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift @@ -6,11 +6,16 @@ enum GatewayDiscoverySelectionSupport { gateway: GatewayDiscoveryModel.DiscoveredGateway, state: AppState) { - if state.remoteTransport == .direct { - state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" - } else { - state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + let preferredTransport = self.preferredTransport( + for: gateway, + current: state.remoteTransport) + if preferredTransport != state.remoteTransport { + state.remoteTransport = preferredTransport } + + state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { OpenClawConfigFile.setRemoteGatewayUrl( host: endpoint.host, @@ -19,4 +24,30 @@ enum GatewayDiscoverySelectionSupport { OpenClawConfigFile.clearRemoteGatewayUrl() } } + + static func preferredTransport( + for gateway: GatewayDiscoveryModel.DiscoveredGateway, + current: AppState.RemoteTransport) -> AppState.RemoteTransport + { + if self.shouldPreferDirectTransport(for: gateway) { + return .direct + } + return current + } + + static func shouldPreferDirectTransport( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool + { + guard GatewayDiscoveryHelpers.directUrl(for: gateway) != nil else { return false } + if gateway.stableID.hasPrefix("tailscale-serve|") { + return true + } + guard let host = GatewayDiscoveryHelpers.resolvedServiceHost(for: gateway)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + else { + return false + } + return host.hasSuffix(".ts.net") + } } diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift index 9e9d43388eb47..9d3c595326143 100644 --- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift @@ -338,13 +338,12 @@ public final class GatewayDiscoveryModel { var attempt = 0 let startedAt = Date() while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 { - let hasResults = await MainActor.run { - if self.filterLocalGateways { - return !self.gateways.isEmpty - } - return self.gateways.contains(where: { !$0.isLocal }) + let shouldContinue = await MainActor.run { + Self.shouldContinueTailscaleServeDiscovery( + currentGateways: self.gateways, + tailscaleServeGateways: self.tailscaleServeFallbackGateways) } - if hasResults { return } + if !shouldContinue { return } let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4) if !beacons.isEmpty { @@ -363,6 +362,15 @@ public final class GatewayDiscoveryModel { } } + static func shouldContinueTailscaleServeDiscovery( + currentGateways _: [DiscoveredGateway], + tailscaleServeGateways: [DiscoveredGateway]) -> Bool + { + // Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the + // probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found. + tailscaleServeGateways.isEmpty + } + private var hasUsableWideAreaResults: Bool { guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false } guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false } diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift index 0994c262ede97..5e7f89fdf45cd 100644 --- a/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleServeGatewayDiscovery.swift @@ -203,6 +203,7 @@ enum TailscaleServeGatewayDiscovery { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = args + process.environment = self.commandEnvironment() let outPipe = Pipe() process.standardOutput = outPipe process.standardError = FileHandle.nullDevice @@ -227,6 +228,19 @@ enum TailscaleServeGatewayDiscovery { return output?.isEmpty == false ? output : nil } + static func commandEnvironment( + base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] + { + var env = base + let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if term.isEmpty { + // The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing, + // which is common for GUI-launched app environments. + env["TERM"] = "dumb" + } + return env + } + private static func parseStatus(_ raw: String) -> TailscaleStatus? { guard let data = raw.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(TailscaleStatus.self, from: data) diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift index 96992b324eb4e..55a6b25f81e99 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryModelTests.swift @@ -121,6 +121,56 @@ struct GatewayDiscoveryModelTests { port: 2201) == "peter@studio.local:2201") } + @Test func `tailscale serve discovery continues when DNS-SD already found a remote gateway`() { + let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Nearby Gateway", + serviceHost: "nearby-gateway.local", + servicePort: 18789, + lanHost: "nearby-gateway.local", + tailnetDns: nil, + sshPort: 22, + gatewayPort: 18789, + cliPath: nil, + stableID: "bonjour|nearby-gateway", + debugID: "bonjour", + isLocal: false) + + #expect(GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery( + currentGateways: [dnsSdGateway], + tailscaleServeGateways: [])) + } + + @Test func `tailscale serve discovery stops after serve result is found`() { + let dnsSdGateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Nearby Gateway", + serviceHost: "nearby-gateway.local", + servicePort: 18789, + lanHost: "nearby-gateway.local", + tailnetDns: nil, + sshPort: 22, + gatewayPort: 18789, + cliPath: nil, + stableID: "bonjour|nearby-gateway", + debugID: "bonjour", + isLocal: false) + let serveGateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Tailscale Gateway", + serviceHost: "gateway-host.tailnet-example.ts.net", + servicePort: 443, + lanHost: nil, + tailnetDns: "gateway-host.tailnet-example.ts.net", + sshPort: 22, + gatewayPort: 443, + cliPath: nil, + stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net", + debugID: "serve", + isLocal: false) + + #expect(!GatewayDiscoveryModel.shouldContinueTailscaleServeDiscovery( + currentGateways: [dnsSdGateway], + tailscaleServeGateways: [serveGateway])) + } + @Test func `dedupe key prefers resolved endpoint across sources`() { let wideArea = GatewayDiscoveryModel.DiscoveredGateway( displayName: "Gateway", diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift new file mode 100644 index 0000000000000..fcfad8d9d85b1 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoverySelectionSupportTests.swift @@ -0,0 +1,90 @@ +import Foundation +import OpenClawDiscovery +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct GatewayDiscoverySelectionSupportTests { + private func makeGateway( + serviceHost: String?, + servicePort: Int?, + tailnetDns: String? = nil, + sshPort: Int = 22, + stableID: String) -> GatewayDiscoveryModel.DiscoveredGateway + { + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: serviceHost, + servicePort: servicePort, + lanHost: nil, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: servicePort, + cliPath: nil, + stableID: stableID, + debugID: UUID().uuidString, + isLocal: false) + } + + @Test func `selecting tailscale serve gateway switches to direct transport`() async { + let tailnetHost = "gateway-host.tailnet-example.ts.net" + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host" + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: tailnetHost, + servicePort: 443, + tailnetDns: tailnetHost, + stableID: "tailscale-serve|\(tailnetHost)"), + state: state) + + #expect(state.remoteTransport == .direct) + #expect(state.remoteUrl == "wss://\(tailnetHost)") + #expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == tailnetHost) + } + } + + @Test func `selecting merged tailnet gateway still switches to direct transport`() async { + let tailnetHost = "gateway-host.tailnet-example.ts.net" + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: tailnetHost, + servicePort: 443, + tailnetDns: tailnetHost, + stableID: "wide-area|openclaw.internal.|gateway-host"), + state: state) + + #expect(state.remoteTransport == .direct) + #expect(state.remoteUrl == "wss://\(tailnetHost)") + } + } + + @Test func `selecting nearby lan gateway keeps ssh transport`() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host" + + GatewayDiscoverySelectionSupport.applyRemoteSelection( + gateway: self.makeGateway( + serviceHost: "nearby-gateway.local", + servicePort: 18789, + stableID: "bonjour|nearby-gateway"), + state: state) + + #expect(state.remoteTransport == .ssh) + #expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local") + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift index 46feb9a795eba..b557a8494d662 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleServeGatewayDiscoveryTests.swift @@ -74,4 +74,25 @@ struct TailscaleServeGatewayDiscoveryTests { #expect(TailscaleServeGatewayDiscovery .resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil) } + + @Test func `adds TERM for GUI-launched tailscale subprocesses`() { + let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [ + "HOME": "/Users/tester", + "PATH": "/usr/bin:/bin", + ]) + + #expect(env["TERM"] == "dumb") + #expect(env["HOME"] == "/Users/tester") + #expect(env["PATH"] == "/usr/bin:/bin") + } + + @Test func `preserves existing TERM when building tailscale subprocess environment`() { + let env = TailscaleServeGatewayDiscovery.commandEnvironment(base: [ + "TERM": "xterm-256color", + "HOME": "/Users/tester", + ]) + + #expect(env["TERM"] == "xterm-256color") + #expect(env["HOME"] == "/Users/tester") + } } From a075baba849e57789ca3984511f2cf685525a9b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:52:23 +0000 Subject: [PATCH 160/311] refactor(browser): scope CDP sessions and harden stale target recovery --- src/agents/tools/browser-tool.actions.ts | 33 +++-- src/agents/tools/browser-tool.test.ts | 27 +++- src/browser/browser-utils.test.ts | 12 ++ src/browser/cdp.test.ts | 20 +++ src/browser/config.test.ts | 22 +++ src/browser/extension-relay.bind-host.test.ts | 49 +++++++ src/browser/pw-session.connections.test.ts | 119 +++++++++++++++ ...ge-for-targetid.extension-fallback.test.ts | 67 ++++++++- src/browser/pw-session.ts | 136 ++++++++++-------- ...-tab-available.prefers-last-target.test.ts | 5 +- .../server-context.loopback-direct-ws.test.ts | 42 ++++++ ...er-context.remote-profile-tab-ops.suite.ts | 5 +- src/browser/server-context.reset.test.ts | 10 +- src/browser/server-context.reset.ts | 8 +- src/browser/server-context.selection.ts | 11 +- 15 files changed, 468 insertions(+), 98 deletions(-) create mode 100644 src/browser/extension-relay.bind-host.test.ts create mode 100644 src/browser/pw-session.connections.test.ts diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 9576889126439..6c156e0cf2dd9 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -74,6 +74,17 @@ function stripTargetIdFromActRequest( return retryRequest as Parameters[1]; } +function canRetryChromeActWithoutTargetId(request: Parameters[1]): boolean { + const typedRequest = request as Partial>; + const kind = + typeof typedRequest.kind === "string" + ? typedRequest.kind + : typeof typedRequest.action === "string" + ? typedRequest.action + : ""; + return kind === "hover" || kind === "scrollIntoView" || kind === "wait"; +} + export async function executeTabsAction(params: { baseUrl?: string; profile?: string; @@ -304,9 +315,18 @@ export async function executeActAction(params: { } catch (err) { if (isChromeStaleTargetError(profile, err)) { const retryRequest = stripTargetIdFromActRequest(request); + const tabs = proxyRequest + ? (( + (await proxyRequest({ + method: "GET", + path: "/tabs", + profile, + })) as { tabs?: unknown[] } + ).tabs ?? []) + : await browserTabs(baseUrl, { profile }).catch(() => []); // Some Chrome relay targetIds can go stale between snapshots and actions. - // Retry once without targetId to let relay use the currently attached tab. - if (retryRequest) { + // Only retry safe read-only actions, and only when exactly one tab remains attached. + if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) { try { const retryResult = proxyRequest ? await proxyRequest({ @@ -323,15 +343,6 @@ export async function executeActAction(params: { // Fall through to explicit stale-target guidance. } } - const tabs = proxyRequest - ? (( - (await proxyRequest({ - method: "GET", - path: "/tabs", - profile, - })) as { tabs?: unknown[] } - ).tabs ?? []) - : await browserTabs(baseUrl, { profile }).catch(() => []); if (!tabs.length) { throw new Error( "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.", diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 3c54cb63633bc..79358cf166564 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -571,17 +571,18 @@ describe("browser tool external content wrapping", () => { describe("browser tool act stale target recovery", () => { registerBrowserToolAfterEachReset(); - it("retries chrome act once without targetId when tab id is stale", async () => { + it("retries safe chrome act once without targetId when exactly one tab remains", async () => { browserActionsMocks.browserAct .mockRejectedValueOnce(new Error("404: tab not found")) .mockResolvedValueOnce({ ok: true }); + browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]); const tool = createBrowserTool(); const result = await tool.execute?.("call-1", { action: "act", profile: "chrome", request: { - action: "click", + kind: "hover", targetId: "stale-tab", ref: "btn-1", }, @@ -591,7 +592,7 @@ describe("browser tool act stale target recovery", () => { expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith( 1, undefined, - expect.objectContaining({ targetId: "stale-tab", action: "click", ref: "btn-1" }), + expect.objectContaining({ targetId: "stale-tab", kind: "hover", ref: "btn-1" }), expect.objectContaining({ profile: "chrome" }), ); expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith( @@ -602,4 +603,24 @@ describe("browser tool act stale target recovery", () => { ); expect(result?.details).toMatchObject({ ok: true }); }); + + it("does not retry mutating chrome act requests without targetId", async () => { + browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found")); + browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]); + + const tool = createBrowserTool(); + await expect( + tool.execute?.("call-1", { + action: "act", + profile: "chrome", + request: { + kind: "click", + targetId: "stale-tab", + ref: "btn-1", + }, + }), + ).rejects.toThrow(/Run action=tabs profile="chrome"/i); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index d5c081668e719..ab6c13d55aa92 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -166,11 +166,23 @@ describe("cdp.helpers", () => { expect(url).toBe("https://connect.example.com/?token=abc"); }); + it("preserves auth and query params when normalizing secure loopback WebSocket CDP URLs", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints( + "wss://user:pass@127.0.0.1:9222/devtools/browser/ABC?token=abc", + ); + expect(url).toBe("https://user:pass@127.0.0.1:9222/?token=abc"); + }); + it("strips a trailing /cdp suffix when normalizing HTTP bases", () => { const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc"); expect(url).toBe("http://127.0.0.1:9222/?token=abc"); }); + it("preserves base prefixes when stripping a trailing /cdp suffix", () => { + const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/browser/cdp?token=abc"); + expect(url).toBe("http://127.0.0.1:9222/browser?token=abc"); + }); + it("adds basic auth headers when credentials are present", () => { const headers = getHeadersWithAuth("https://user:pass@example.com"); expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 44dbdd70e3073..524dfe13bb546 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -336,6 +336,26 @@ describe("cdp", () => { expect(normalized).toBe("ws://192.168.1.202:18850/devtools/browser/ABC"); }); + it("keeps existing websocket query params when appending remote CDP query params", () => { + const normalized = normalizeCdpWsUrl( + "ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token", + "http://127.0.0.1:9222?token=cdp-token&apiKey=abc", + ); + expect(normalized).toBe( + "ws://127.0.0.1:9222/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc", + ); + }); + + it("rewrites wildcard bind addresses to secure remote CDP hosts without clobbering websocket params", () => { + const normalized = normalizeCdpWsUrl( + "ws://0.0.0.0:3000/devtools/browser/ABC?session=1&token=ws-token", + "https://user:pass@example.com:9443?token=cdp-token&apiKey=abc", + ); + expect(normalized).toBe( + "wss://user:pass@example.com:9443/devtools/browser/ABC?session=1&token=ws-token&apiKey=abc", + ); + }); + it("upgrades ws to wss when CDP uses https", () => { const normalized = normalizeCdpWsUrl( "ws://production-sfo.browserless.io", diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index eb77dc5341da8..d2643a6784b05 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -176,6 +176,28 @@ describe("browser config", () => { expect(profile?.cdpIsLoopback).toBe(false); }); + it("preserves loopback direct WebSocket cdpUrl for explicit profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + localws: { + cdpUrl: "ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key", + color: "#0066CC", + }, + }, + }); + const profile = resolveProfile(resolved, "localws"); + expect(profile?.cdpUrl).toBe("ws://127.0.0.1:9222/devtools/browser/ABC?token=test-key"); + expect(profile?.cdpPort).toBe(9222); + expect(profile?.cdpIsLoopback).toBe(true); + }); + + it("trims relayBindHost when configured", () => { + const resolved = resolveBrowserConfig({ + relayBindHost: " 0.0.0.0 ", + }); + expect(resolved.relayBindHost).toBe("0.0.0.0"); + }); + it("rejects unsupported protocols", () => { expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow( "must be http(s) or ws(s)", diff --git a/src/browser/extension-relay.bind-host.test.ts b/src/browser/extension-relay.bind-host.test.ts new file mode 100644 index 0000000000000..a029a2f1a9516 --- /dev/null +++ b/src/browser/extension-relay.bind-host.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; +import { getFreePort } from "./test-port.js"; + +describe("chrome extension relay bindHost coordination", () => { + let cdpUrl = ""; + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); + process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; + }); + + afterEach(async () => { + if (cdpUrl) { + await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); + cdpUrl = ""; + } + envSnapshot.restore(); + }); + + it("rebinds the relay when concurrent callers request different bind hosts", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + + const [first, second] = await Promise.all([ + ensureChromeExtensionRelayServer({ cdpUrl }), + ensureChromeExtensionRelayServer({ cdpUrl, bindHost: "0.0.0.0" }), + ]); + + const settled = await ensureChromeExtensionRelayServer({ + cdpUrl, + bindHost: "0.0.0.0", + }); + + expect(first.port).toBe(port); + expect(second.port).toBe(port); + expect(second).not.toBe(first); + expect(second.bindHost).toBe("0.0.0.0"); + expect(settled).toBe(second); + + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + }); +}); diff --git a/src/browser/pw-session.connections.test.ts b/src/browser/pw-session.connections.test.ts new file mode 100644 index 0000000000000..abb6946d6104f --- /dev/null +++ b/src/browser/pw-session.connections.test.ts @@ -0,0 +1,119 @@ +import { chromium } from "playwright-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as chromeModule from "./chrome.js"; +import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js"; + +const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); +const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); + +type BrowserMockBundle = { + browser: import("playwright-core").Browser; + browserClose: ReturnType; +}; + +function makeBrowser(targetId: string, url: string): BrowserMockBundle { + let context: import("playwright-core").BrowserContext; + const browserClose = vi.fn(async () => {}); + const page = { + on: vi.fn(), + context: () => context, + title: vi.fn(async () => `title:${targetId}`), + url: vi.fn(() => url), + } as unknown as import("playwright-core").Page; + + context = { + pages: () => [page], + on: vi.fn(), + newCDPSession: vi.fn(async () => ({ + send: vi.fn(async (method: string) => + method === "Target.getTargetInfo" ? { targetInfo: { targetId } } : {}, + ), + detach: vi.fn(async () => {}), + })), + } as unknown as import("playwright-core").BrowserContext; + + const browser = { + contexts: () => [context], + on: vi.fn(), + off: vi.fn(), + close: browserClose, + } as unknown as import("playwright-core").Browser; + + return { browser, browserClose }; +} + +afterEach(async () => { + connectOverCdpSpy.mockReset(); + getChromeWebSocketUrlSpy.mockReset(); + await closePlaywrightBrowserConnection().catch(() => {}); +}); + +describe("pw-session connection scoping", () => { + it("does not share in-flight connectOverCDP promises across different cdpUrls", async () => { + const browserA = makeBrowser("A", "https://a.example"); + const browserB = makeBrowser("B", "https://b.example"); + let resolveA: ((value: import("playwright-core").Browser) => void) | undefined; + + connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => { + const endpointText = String(args[0]); + if (endpointText === "http://127.0.0.1:9222") { + return await new Promise((resolve) => { + resolveA = resolve; + }); + } + if (endpointText === "http://127.0.0.1:9333") { + return browserB.browser; + } + throw new Error(`unexpected endpoint: ${endpointText}`); + }) as never); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + const pendingA = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); + await Promise.resolve(); + const pendingB = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" }); + + await vi.waitFor(() => { + expect(connectOverCdpSpy).toHaveBeenCalledTimes(2); + }); + expect(connectOverCdpSpy).toHaveBeenNthCalledWith( + 1, + "http://127.0.0.1:9222", + expect.any(Object), + ); + expect(connectOverCdpSpy).toHaveBeenNthCalledWith( + 2, + "http://127.0.0.1:9333", + expect.any(Object), + ); + + resolveA?.(browserA.browser); + const [pagesA, pagesB] = await Promise.all([pendingA, pendingB]); + expect(pagesA.map((page) => page.targetId)).toEqual(["A"]); + expect(pagesB.map((page) => page.targetId)).toEqual(["B"]); + }); + + it("closes only the requested scoped connection", async () => { + const browserA = makeBrowser("A", "https://a.example"); + const browserB = makeBrowser("B", "https://b.example"); + + connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => { + const endpointText = String(args[0]); + if (endpointText === "http://127.0.0.1:9222") { + return browserA.browser; + } + if (endpointText === "http://127.0.0.1:9333") { + return browserB.browser; + } + throw new Error(`unexpected endpoint: ${endpointText}`); + }) as never); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" }); + await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" }); + + await closePlaywrightBrowserConnection({ cdpUrl: "http://127.0.0.1:9222" }); + + expect(browserA.browserClose).toHaveBeenCalledTimes(1); + expect(browserB.browserClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index b9908c5f22db7..f1909ad33fb85 100644 --- a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -1,11 +1,17 @@ import { chromium } from "playwright-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as chromeModule from "./chrome.js"; import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js"; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); +afterEach(async () => { + connectOverCdpSpy.mockClear(); + getChromeWebSocketUrlSpy.mockClear(); + await closePlaywrightBrowserConnection().catch(() => {}); +}); + describe("pw-session getPageForTargetId", () => { it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => { connectOverCdpSpy.mockClear(); @@ -50,4 +56,63 @@ describe("pw-session getPageForTargetId", () => { await closePlaywrightBrowserConnection(); expect(browserClose).toHaveBeenCalled(); }); + + it("uses the shared HTTP-base normalization when falling back to /json/list for direct WebSocket CDP URLs", async () => { + const pageOn = vi.fn(); + const contextOn = vi.fn(); + const browserOn = vi.fn(); + const browserClose = vi.fn(async () => {}); + + const context = { + pages: () => [], + on: contextOn, + newCDPSession: vi.fn(async () => { + throw new Error("Not allowed"); + }), + } as unknown as import("playwright-core").BrowserContext; + + const pageA = { + on: pageOn, + context: () => context, + url: () => "https://alpha.example", + } as unknown as import("playwright-core").Page; + const pageB = { + on: pageOn, + context: () => context, + url: () => "https://beta.example", + } as unknown as import("playwright-core").Page; + + (context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB]; + + const browser = { + contexts: () => [context], + on: browserOn, + close: browserClose, + } as unknown as import("playwright-core").Browser; + + connectOverCdpSpy.mockResolvedValue(browser); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => [ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ], + } as Response); + + try { + const resolved = await getPageForTargetId({ + cdpUrl: "ws://127.0.0.1:18792/devtools/browser/SESSION?token=abc", + targetId: "TARGET_B", + }); + expect(resolved).toBe(pageB); + expect(fetchSpy).toHaveBeenCalledWith( + "http://127.0.0.1:18792/json/list?token=abc", + expect.any(Object), + ); + } finally { + fetchSpy.mockRestore(); + } + }); }); diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 55d41c60c0a75..a5f1b11ec02c6 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -113,8 +113,8 @@ const MAX_CONSOLE_MESSAGES = 500; const MAX_PAGE_ERRORS = 200; const MAX_NETWORK_REQUESTS = 500; -let cached: ConnectedBrowser | null = null; -let connecting: Promise | null = null; +const cachedByCdpUrl = new Map(); +const connectingByCdpUrl = new Map>(); function normalizeCdpUrl(raw: string) { return raw.replace(/\/$/, ""); @@ -328,9 +328,11 @@ function observeBrowser(browser: Browser) { async function connectBrowser(cdpUrl: string): Promise { const normalized = normalizeCdpUrl(cdpUrl); - if (cached?.cdpUrl === normalized) { + const cached = cachedByCdpUrl.get(normalized); + if (cached) { return cached; } + const connecting = connectingByCdpUrl.get(normalized); if (connecting) { return await connecting; } @@ -348,12 +350,13 @@ async function connectBrowser(cdpUrl: string): Promise { chromium.connectOverCDP(endpoint, { timeout, headers }), ); const onDisconnected = () => { - if (cached?.browser === browser) { - cached = null; + const current = cachedByCdpUrl.get(normalized); + if (current?.browser === browser) { + cachedByCdpUrl.delete(normalized); } }; const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected }; - cached = connected; + cachedByCdpUrl.set(normalized, connected); browser.on("disconnected", onDisconnected); observeBrowser(browser); return connected; @@ -370,11 +373,12 @@ async function connectBrowser(cdpUrl: string): Promise { throw new Error(message); }; - connecting = connectWithRetry().finally(() => { - connecting = null; + const pending = connectWithRetry().finally(() => { + connectingByCdpUrl.delete(normalized); }); + connectingByCdpUrl.set(normalized, pending); - return await connecting; + return await pending; } async function getAllPages(browser: Browser): Promise { @@ -423,34 +427,29 @@ async function findPageByTargetId( // fall back to URL-based matching using the /json/list endpoint if (cdpUrl) { try { - const baseUrl = cdpUrl - .replace(/\/+$/, "") - .replace(/^ws:/, "http:") - .replace(/\/cdp$/, ""); - const listUrl = `${baseUrl}/json/list`; - const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) }); - if (response.ok) { - const targets = (await response.json()) as Array<{ + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); + const targets = await fetchJson< + Array<{ id: string; url: string; title?: string; - }>; - const target = targets.find((t) => t.id === targetId); - if (target) { - // Try to find a page with matching URL - const urlMatch = pages.filter((p) => p.url() === target.url); - if (urlMatch.length === 1) { - return urlMatch[0]; - } - // If multiple URL matches, use index-based matching as fallback - // This works when Playwright and the relay enumerate tabs in the same order - if (urlMatch.length > 1) { - const sameUrlTargets = targets.filter((t) => t.url === target.url); - if (sameUrlTargets.length === urlMatch.length) { - const idx = sameUrlTargets.findIndex((t) => t.id === targetId); - if (idx >= 0 && idx < urlMatch.length) { - return urlMatch[idx]; - } + }> + >(appendCdpPath(cdpHttpBase, "/json/list"), 2000); + const target = targets.find((t) => t.id === targetId); + if (target) { + // Try to find a page with matching URL + const urlMatch = pages.filter((p) => p.url() === target.url); + if (urlMatch.length === 1) { + return urlMatch[0]; + } + // If multiple URL matches, use index-based matching as fallback + // This works when Playwright and the relay enumerate tabs in the same order + if (urlMatch.length > 1) { + const sameUrlTargets = targets.filter((t) => t.url === target.url); + if (sameUrlTargets.length === urlMatch.length) { + const idx = sameUrlTargets.findIndex((t) => t.id === targetId); + if (idx >= 0 && idx < urlMatch.length) { + return urlMatch[idx]; } } } @@ -539,17 +538,32 @@ export function refLocator(page: Page, ref: string) { return page.locator(`aria-ref=${normalized}`); } -export async function closePlaywrightBrowserConnection(): Promise { - const cur = cached; - cached = null; - connecting = null; - if (!cur) { +export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string }): Promise { + const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null; + + if (normalized) { + const cur = cachedByCdpUrl.get(normalized); + cachedByCdpUrl.delete(normalized); + connectingByCdpUrl.delete(normalized); + if (!cur) { + return; + } + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + await cur.browser.close().catch(() => {}); return; } - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); + + const connections = Array.from(cachedByCdpUrl.values()); + cachedByCdpUrl.clear(); + connectingByCdpUrl.clear(); + for (const cur of connections) { + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } + await cur.browser.close().catch(() => {}); } - await cur.browser.close().catch(() => {}); } function cdpSocketNeedsAttach(wsUrl: string): boolean { @@ -655,31 +669,29 @@ export async function forceDisconnectPlaywrightForTarget(opts: { reason?: string; }): Promise { const normalized = normalizeCdpUrl(opts.cdpUrl); - if (cached?.cdpUrl !== normalized) { + const cur = cachedByCdpUrl.get(normalized); + if (!cur) { return; } - const cur = cached; - cached = null; - // Also clear `connecting` so the next call does a fresh connectOverCDP + cachedByCdpUrl.delete(normalized); + // Also clear the per-url in-flight connect so the next call does a fresh connectOverCDP // rather than awaiting a stale promise. - connecting = null; - if (cur) { - // Remove the "disconnected" listener to prevent the old browser's teardown - // from racing with a fresh connection and nulling the new `cached`. - if (cur.onDisconnected && typeof cur.browser.off === "function") { - cur.browser.off("disconnected", cur.onDisconnected); - } - - // Best-effort: kill any stuck JS to unblock the target's execution context before we - // disconnect Playwright's CDP connection. - const targetId = opts.targetId?.trim() || ""; - if (targetId) { - await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); - } + connectingByCdpUrl.delete(normalized); + // Remove the "disconnected" listener to prevent the old browser's teardown + // from racing with a fresh connection and nulling the new cached entry. + if (cur.onDisconnected && typeof cur.browser.off === "function") { + cur.browser.off("disconnected", cur.onDisconnected); + } - // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. - cur.browser.close().catch(() => {}); + // Best-effort: kill any stuck JS to unblock the target's execution context before we + // disconnect Playwright's CDP connection. + const targetId = opts.targetId?.trim() || ""; + if (targetId) { + await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); } + + // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. + cur.browser.close().catch(() => {}); } /** diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 10de9ee18bcdd..13c5f82e31de2 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -99,7 +99,7 @@ describe("browser server-context ensureTabAvailable", () => { expect(second.targetId).toBe("A"); }); - it("falls back to the only attached tab when an invalid targetId is provided (extension)", async () => { + it("rejects invalid targetId even when only one extension tab remains", async () => { const responses = [ [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], @@ -109,8 +109,7 @@ describe("browser server-context ensureTabAvailable", () => { const ctx = createBrowserRouteContext({ getState: () => state }); const chrome = ctx.forProfile("chrome"); - const chosen = await chrome.ensureTabAvailable("NOT_A_TAB"); - expect(chosen.targetId).toBe("A"); + await expect(chrome.ensureTabAvailable("NOT_A_TAB")).rejects.toThrow(/tab not found/i); }); it("returns a descriptive message when no extension tabs are attached", async () => { diff --git a/src/browser/server-context.loopback-direct-ws.test.ts b/src/browser/server-context.loopback-direct-ws.test.ts index 9f6512fab4eaa..127b329a7e842 100644 --- a/src/browser/server-context.loopback-direct-ws.test.ts +++ b/src/browser/server-context.loopback-direct-ws.test.ts @@ -97,4 +97,46 @@ describe("browser server-context loopback direct WebSocket profiles", () => { expect.any(Object), ); }); + + it("uses an HTTPS /json base for secure direct WebSocket profiles with a /cdp suffix", async () => { + const fetchMock = vi.fn(async (url: unknown) => { + const u = String(url); + if (u === "https://127.0.0.1:18800/json/list?token=abc") { + return { + ok: true, + json: async () => [ + { + id: "T2", + title: "Secure Tab", + url: "https://example.com", + webSocketDebuggerUrl: "wss://127.0.0.1/devtools/page/T2", + type: "page", + }, + ], + } as unknown as Response; + } + if (u === "https://127.0.0.1:18800/json/activate/T2?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + if (u === "https://127.0.0.1:18800/json/close/T2?token=abc") { + return { ok: true, json: async () => ({}) } as unknown as Response; + } + throw new Error(`unexpected fetch: ${u}`); + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.profiles.openclaw = { + cdpUrl: "wss://127.0.0.1:18800/cdp?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + const tabs = await openclaw.listTabs(); + expect(tabs.map((tab) => tab.targetId)).toEqual(["T2"]); + + await openclaw.focusTab("T2"); + await openclaw.closeTab("T2"); + }); }); diff --git a/src/browser/server-context.remote-profile-tab-ops.suite.ts b/src/browser/server-context.remote-profile-tab-ops.suite.ts index 746a8c87f5318..e0bd5815199f5 100644 --- a/src/browser/server-context.remote-profile-tab-ops.suite.ts +++ b/src/browser/server-context.remote-profile-tab-ops.suite.ts @@ -139,7 +139,7 @@ describe("browser server-context remote profile tab operations", () => { expect(second.targetId).toBe("A"); }); - it("falls back to the only tab for remote profiles when targetId is stale", async () => { + it("rejects stale targetId for remote profiles even when only one tab remains", async () => { const responses = [ [{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }], [{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }], @@ -151,8 +151,7 @@ describe("browser server-context remote profile tab operations", () => { } as unknown as Awaited>); const { remote } = createRemoteRouteHarness(); - const chosen = await remote.ensureTabAvailable("STALE_TARGET"); - expect(chosen.targetId).toBe("T1"); + await expect(remote.ensureTabAvailable("STALE_TARGET")).rejects.toThrow(/tab not found/i); }); it("keeps rejecting stale targetId for remote profiles when multiple tabs exist", async () => { diff --git a/src/browser/server-context.reset.test.ts b/src/browser/server-context.reset.test.ts index 09a20b48edf7b..7e74ffd3881b5 100644 --- a/src/browser/server-context.reset.test.ts +++ b/src/browser/server-context.reset.test.ts @@ -112,7 +112,9 @@ describe("createProfileResetOps", () => { }); expect(isHttpReachable).toHaveBeenCalledWith(300); expect(stopRunningBrowser).toHaveBeenCalledTimes(1); - expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledTimes(1); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18800", + }); expect(trashMocks.movePathToTrash).toHaveBeenCalledWith(profileDir); }); @@ -132,5 +134,11 @@ describe("createProfileResetOps", () => { await ops.resetProfile(); expect(stopRunningBrowser).not.toHaveBeenCalled(); expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenCalledTimes(2); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenNthCalledWith(1, { + cdpUrl: "http://127.0.0.1:18800", + }); + expect(pwAiMocks.closePlaywrightBrowserConnection).toHaveBeenNthCalledWith(2, { + cdpUrl: "http://127.0.0.1:18800", + }); }); }); diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index 134db475f61b0..7f890a2184cb5 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -16,10 +16,10 @@ type ResetOps = { resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>; }; -async function closePlaywrightBrowserConnection(): Promise { +async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise { try { const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(); + await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); } catch { // ignore } @@ -48,14 +48,14 @@ export function createProfileResetOps({ const httpReachable = await isHttpReachable(300); if (httpReachable && !profileState.running) { // Port in use but not by us - kill it. - await closePlaywrightBrowserConnection(); + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); } if (profileState.running) { await stopRunningBrowser(); } - await closePlaywrightBrowserConnection(); + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); if (!fs.existsSync(userDataDir)) { return { moved: false, from: userDataDir }; diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 3b0a267f2eb60..7afeca36c5c44 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -86,16 +86,7 @@ export function createProfileSelectionOps({ return page ?? candidates.at(0) ?? null; }; - let chosen = targetId ? resolveById(targetId) : pickDefault(); - if ( - !chosen && - (profile.driver === "extension" || !profile.cdpIsLoopback) && - candidates.length === 1 - ) { - // If an agent passes a stale/foreign targetId but only one candidate remains, - // recover by using that tab instead of failing hard. - chosen = candidates[0] ?? null; - } + const chosen = targetId ? resolveById(targetId) : pickDefault(); if (chosen === "AMBIGUOUS") { throw new Error("ambiguous target id prefix"); From 0ecfd37b44656f3b1ef08268e3b5c6d975107a26 Mon Sep 17 00:00:00 2001 From: shichangs <46870204+shichangs@users.noreply.github.com> Date: Mon, 9 Mar 2026 04:21:20 +0800 Subject: [PATCH 161/311] feat: add local backup CLI (#40163) Merged via squash. Prepared head SHA: ed46625ae20c71e5f46d51aa801cefe4ef1c92e3 Co-authored-by: shichangs <46870204+shichangs@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 2 + docs/cli/backup.md | 76 ++++ docs/cli/index.md | 4 + docs/cli/reset.md | 3 + docs/cli/uninstall.md | 3 + src/cli/command-secret-gateway.test.ts | 17 +- src/cli/program/command-registry.test.ts | 8 + src/cli/program/command-registry.ts | 13 + src/cli/program/preaction.test.ts | 14 + src/cli/program/preaction.ts | 2 +- src/cli/program/register.backup.test.ts | 104 ++++++ src/cli/program/register.backup.ts | 92 +++++ src/commands/backup-shared.ts | 254 +++++++++++++ src/commands/backup-verify.test.ts | 274 ++++++++++++++ src/commands/backup-verify.ts | 304 ++++++++++++++++ src/commands/backup.atomic.test.ts | 133 +++++++ src/commands/backup.test.ts | 434 +++++++++++++++++++++++ src/commands/backup.ts | 382 ++++++++++++++++++++ src/commands/reset.test.ts | 69 ++++ src/commands/reset.ts | 5 + src/commands/uninstall.test.ts | 66 ++++ src/commands/uninstall.ts | 9 + 22 files changed, 2256 insertions(+), 12 deletions(-) create mode 100644 docs/cli/backup.md create mode 100644 src/cli/program/register.backup.test.ts create mode 100644 src/cli/program/register.backup.ts create mode 100644 src/commands/backup-shared.ts create mode 100644 src/commands/backup-verify.test.ts create mode 100644 src/commands/backup-verify.ts create mode 100644 src/commands/backup.atomic.test.ts create mode 100644 src/commands/backup.test.ts create mode 100644 src/commands/backup.ts create mode 100644 src/commands/reset.test.ts create mode 100644 src/commands/uninstall.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index deff9b4b06ea5..d1f5ca2810fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. - Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. - macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. +- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. +- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. ### Fixes diff --git a/docs/cli/backup.md b/docs/cli/backup.md new file mode 100644 index 0000000000000..a39b0fefac6d6 --- /dev/null +++ b/docs/cli/backup.md @@ -0,0 +1,76 @@ +--- +summary: "CLI reference for `openclaw backup` (create local backup archives)" +read_when: + - You want a first-class backup archive for local OpenClaw state + - You want to preview which paths would be included before reset or uninstall +title: "backup" +--- + +# `openclaw backup` + +Create a local backup archive for OpenClaw state, config, credentials, sessions, and optionally workspaces. + +```bash +openclaw backup create +openclaw backup create --output ~/Backups +openclaw backup create --dry-run --json +openclaw backup create --verify +openclaw backup create --no-include-workspace +openclaw backup create --only-config +openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz +``` + +## Notes + +- The archive includes a `manifest.json` file with the resolved source paths and archive layout. +- Default output is a timestamped `.tar.gz` archive in the current working directory. +- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location. +- Existing archive files are never overwritten. +- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion. +- `openclaw backup verify ` validates that the archive contains exactly one root manifest, rejects traversal-style archive paths, and checks that every manifest-declared payload exists in the tarball. +- `openclaw backup create --verify` runs that validation immediately after writing the archive. +- `openclaw backup create --only-config` backs up just the active JSON config file. + +## What gets backed up + +`openclaw backup create` plans backup sources from your local OpenClaw install: + +- The state directory returned by OpenClaw's local state resolver, usually `~/.openclaw` +- The active config file path +- The OAuth / credentials directory +- Workspace directories discovered from the current config, unless you pass `--no-include-workspace` + +If you use `--only-config`, OpenClaw skips state, credentials, and workspace discovery and archives only the active config file path. + +OpenClaw canonicalizes paths before building the archive. If config, credentials, or a workspace already live inside the state directory, they are not duplicated as separate top-level backup sources. Missing paths are skipped. + +The archive payload stores file contents from those source trees, and the embedded `manifest.json` records the resolved absolute source paths plus the archive layout used for each asset. + +## Invalid config behavior + +`openclaw backup` intentionally bypasses the normal config preflight so it can still help during recovery. Because workspace discovery depends on a valid config, `openclaw backup create` now fails fast when the config file exists but is invalid and workspace backup is still enabled. + +If you still want a partial backup in that situation, rerun: + +```bash +openclaw backup create --no-include-workspace +``` + +That keeps state, config, and credentials in scope while skipping workspace discovery entirely. + +If you only need a copy of the config file itself, `--only-config` also works when the config is malformed because it does not rely on parsing the config for workspace discovery. + +## Size and performance + +OpenClaw does not enforce a built-in maximum backup size or per-file size limit. + +Practical limits come from the local machine and destination filesystem: + +- Available space for the temporary archive write plus the final archive +- Time to walk large workspace trees and compress them into a `.tar.gz` +- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup verify` +- Filesystem behavior at the destination path. OpenClaw prefers a no-overwrite hard-link publish step and falls back to exclusive copy when hard links are unsupported + +Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`. + +For the smallest archive, use `--only-config`. diff --git a/docs/cli/index.md b/docs/cli/index.md index 634e2cdef0eae..fdee80038c0e6 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -19,6 +19,7 @@ This page describes the current CLI behavior. If commands change, update this do - [`completion`](/cli/completion) - [`doctor`](/cli/doctor) - [`dashboard`](/cli/dashboard) +- [`backup`](/cli/backup) - [`reset`](/cli/reset) - [`uninstall`](/cli/uninstall) - [`update`](/cli/update) @@ -103,6 +104,9 @@ openclaw [--dev] [--profile ] completion doctor dashboard + backup + create + verify security audit secrets diff --git a/docs/cli/reset.md b/docs/cli/reset.md index a94da78f3be25..df1423908667d 100644 --- a/docs/cli/reset.md +++ b/docs/cli/reset.md @@ -11,7 +11,10 @@ title: "reset" Reset local config/state (keeps the CLI installed). ```bash +openclaw backup create openclaw reset openclaw reset --dry-run openclaw reset --scope config+creds+sessions --yes --non-interactive ``` + +Run `openclaw backup create` first if you want a restorable snapshot before removing local state. diff --git a/docs/cli/uninstall.md b/docs/cli/uninstall.md index 9c269eeeb35f6..77333f6265146 100644 --- a/docs/cli/uninstall.md +++ b/docs/cli/uninstall.md @@ -11,7 +11,10 @@ title: "uninstall" Uninstall the gateway service + local data (CLI remains). ```bash +openclaw backup create openclaw uninstall openclaw uninstall --all --yes openclaw uninstall --dry-run ``` + +Run `openclaw backup create` first if you want a restorable snapshot before removing state or workspaces. diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 9f1f6c402e58e..7929cdbdafc1c 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -273,22 +273,17 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); it("fails when configured refs remain unresolved after gateway assignments are applied", async () => { + const envKey = "TALK_API_KEY_STRICT_UNRESOLVED"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/talk\.apiKey is unresolved in the active runtime snapshot/i); + await withEnvValue(envKey, undefined, async () => { + await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( + /talk\.apiKey is unresolved in the active runtime snapshot/i, + ); + }); }); it("allows unresolved refs when gateway diagnostics mark the target as inactive", async () => { diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index 3fc44592ce923..329a28a659fe7 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -11,6 +11,13 @@ vi.mock("./register.agent.js", () => ({ }, })); +vi.mock("./register.backup.js", () => ({ + registerBackupCommand: (program: Command) => { + const backup = program.command("backup"); + backup.command("create"); + }, +})); + vi.mock("./register.maintenance.js", () => ({ registerMaintenanceCommands: (program: Command) => { program.command("doctor"); @@ -67,6 +74,7 @@ describe("command-registry", () => { expect(names).toContain("config"); expect(names).toContain("memory"); expect(names).toContain("agents"); + expect(names).toContain("backup"); expect(names).toContain("browser"); expect(names).toContain("sessions"); expect(names).not.toContain("agent"); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 16416c87e0ae3..3e2338f347572 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -92,6 +92,19 @@ const coreEntries: CoreCliEntry[] = [ mod.registerConfigCli(program); }, }, + { + commands: [ + { + name: "backup", + description: "Create and verify local backup archives for OpenClaw state", + hasSubcommands: true, + }, + ], + register: async ({ program }) => { + const mod = await import("./register.backup.js"); + mod.registerBackupCommand(program); + }, + }, { commands: [ { diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index f99b9f5b29154..4353b8a0d18dd 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -80,6 +80,11 @@ describe("registerPreActionHooks", () => { function buildProgram() { const program = new Command().name("openclaw"); program.command("status").action(() => {}); + program + .command("backup") + .command("create") + .option("--json") + .action(() => {}); program.command("doctor").action(() => {}); program.command("completion").action(() => {}); program.command("secrets").action(() => {}); @@ -226,6 +231,15 @@ describe("registerPreActionHooks", () => { expect(ensureConfigReadyMock).not.toHaveBeenCalled(); }); + it("bypasses config guard for backup create", async () => { + await runPreAction({ + parseArgv: ["backup", "create"], + processArgv: ["node", "openclaw", "backup", "create", "--json"], + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + }); + beforeAll(() => { program = buildProgram(); const hooks = ( diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index e1ce076a52825..5e029c8485843 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -36,7 +36,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "status", "health", ]); -const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]); +const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]); const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]); let configGuardModulePromise: Promise | undefined; let pluginRegistryModulePromise: Promise | undefined; diff --git a/src/cli/program/register.backup.test.ts b/src/cli/program/register.backup.test.ts new file mode 100644 index 0000000000000..b0f62cb97bcc8 --- /dev/null +++ b/src/cli/program/register.backup.test.ts @@ -0,0 +1,104 @@ +import { Command } from "commander"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const backupCreateCommand = vi.fn(); +const backupVerifyCommand = vi.fn(); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +vi.mock("../../commands/backup.js", () => ({ + backupCreateCommand, +})); + +vi.mock("../../commands/backup-verify.js", () => ({ + backupVerifyCommand, +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +let registerBackupCommand: typeof import("./register.backup.js").registerBackupCommand; + +beforeAll(async () => { + ({ registerBackupCommand } = await import("./register.backup.js")); +}); + +describe("registerBackupCommand", () => { + async function runCli(args: string[]) { + const program = new Command(); + registerBackupCommand(program); + await program.parseAsync(args, { from: "user" }); + } + + beforeEach(() => { + vi.clearAllMocks(); + backupCreateCommand.mockResolvedValue(undefined); + backupVerifyCommand.mockResolvedValue(undefined); + }); + + it("runs backup create with forwarded options", async () => { + await runCli(["backup", "create", "--output", "/tmp/backups", "--json", "--dry-run"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + output: "/tmp/backups", + json: true, + dryRun: true, + verify: false, + onlyConfig: false, + includeWorkspace: true, + }), + ); + }); + + it("honors --no-include-workspace", async () => { + await runCli(["backup", "create", "--no-include-workspace"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + includeWorkspace: false, + }), + ); + }); + + it("forwards --verify to backup create", async () => { + await runCli(["backup", "create", "--verify"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + verify: true, + }), + ); + }); + + it("forwards --only-config to backup create", async () => { + await runCli(["backup", "create", "--only-config"]); + + expect(backupCreateCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + onlyConfig: true, + }), + ); + }); + + it("runs backup verify with forwarded options", async () => { + await runCli(["backup", "verify", "/tmp/openclaw-backup.tar.gz", "--json"]); + + expect(backupVerifyCommand).toHaveBeenCalledWith( + runtime, + expect.objectContaining({ + archive: "/tmp/openclaw-backup.tar.gz", + json: true, + }), + ); + }); +}); diff --git a/src/cli/program/register.backup.ts b/src/cli/program/register.backup.ts new file mode 100644 index 0000000000000..fc928f0ff3a58 --- /dev/null +++ b/src/cli/program/register.backup.ts @@ -0,0 +1,92 @@ +import type { Command } from "commander"; +import { backupVerifyCommand } from "../../commands/backup-verify.js"; +import { backupCreateCommand } from "../../commands/backup.js"; +import { defaultRuntime } from "../../runtime.js"; +import { formatDocsLink } from "../../terminal/links.js"; +import { theme } from "../../terminal/theme.js"; +import { runCommandWithRuntime } from "../cli-utils.js"; +import { formatHelpExamples } from "../help-format.js"; + +export function registerBackupCommand(program: Command) { + const backup = program + .command("backup") + .description("Create and verify local backup archives for OpenClaw state") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/backup", "docs.openclaw.ai/cli/backup")}\n`, + ); + + backup + .command("create") + .description("Write a backup archive for config, credentials, sessions, and workspaces") + .option("--output ", "Archive path or destination directory") + .option("--json", "Output JSON", false) + .option("--dry-run", "Print the backup plan without writing the archive", false) + .option("--verify", "Verify the archive after writing it", false) + .option("--only-config", "Back up only the active JSON config file", false) + .option("--no-include-workspace", "Exclude workspace directories from the backup") + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + ["openclaw backup create", "Create a timestamped backup in the current directory."], + [ + "openclaw backup create --output ~/Backups", + "Write the archive into an existing backup directory.", + ], + [ + "openclaw backup create --dry-run --json", + "Preview the archive plan without writing any files.", + ], + [ + "openclaw backup create --verify", + "Create the archive and immediately validate its manifest and payload layout.", + ], + [ + "openclaw backup create --no-include-workspace", + "Back up state/config without agent workspace files.", + ], + ["openclaw backup create --only-config", "Back up only the active JSON config file."], + ])}`, + ) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await backupCreateCommand(defaultRuntime, { + output: opts.output as string | undefined, + json: Boolean(opts.json), + dryRun: Boolean(opts.dryRun), + verify: Boolean(opts.verify), + onlyConfig: Boolean(opts.onlyConfig), + includeWorkspace: opts.includeWorkspace as boolean, + }); + }); + }); + + backup + .command("verify ") + .description("Validate a backup archive and its embedded manifest") + .option("--json", "Output JSON", false) + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + [ + "openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz", + "Check that the archive structure and manifest are intact.", + ], + [ + "openclaw backup verify ~/Backups/latest.tar.gz --json", + "Emit machine-readable verification output.", + ], + ])}`, + ) + .action(async (archive, opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await backupVerifyCommand(defaultRuntime, { + archive: archive as string, + json: Boolean(opts.json), + }); + }); + }); +} diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts new file mode 100644 index 0000000000000..b4b6961bbaabb --- /dev/null +++ b/src/commands/backup-shared.ts @@ -0,0 +1,254 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + readConfigFileSnapshot, + resolveConfigPath, + resolveOAuthDir, + resolveStateDir, +} from "../config/config.js"; +import { formatSessionArchiveTimestamp } from "../config/sessions/artifacts.js"; +import { pathExists, shortenHomePath } from "../utils.js"; +import { buildCleanupPlan, isPathWithin } from "./cleanup-utils.js"; + +export type BackupAssetKind = "state" | "config" | "credentials" | "workspace"; +export type BackupSkipReason = "covered" | "missing"; + +export type BackupAsset = { + kind: BackupAssetKind; + sourcePath: string; + displayPath: string; + archivePath: string; +}; + +export type SkippedBackupAsset = { + kind: BackupAssetKind; + sourcePath: string; + displayPath: string; + reason: BackupSkipReason; + coveredBy?: string; +}; + +export type BackupPlan = { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; + included: BackupAsset[]; + skipped: SkippedBackupAsset[]; +}; + +type BackupAssetCandidate = { + kind: BackupAssetKind; + sourcePath: string; + canonicalPath: string; + exists: boolean; +}; + +function backupAssetPriority(kind: BackupAssetKind): number { + switch (kind) { + case "state": + return 0; + case "config": + return 1; + case "credentials": + return 2; + case "workspace": + return 3; + } +} + +export function buildBackupArchiveRoot(nowMs = Date.now()): string { + return `${formatSessionArchiveTimestamp(nowMs)}-openclaw-backup`; +} + +export function buildBackupArchiveBasename(nowMs = Date.now()): string { + return `${buildBackupArchiveRoot(nowMs)}.tar.gz`; +} + +export function encodeAbsolutePathForBackupArchive(sourcePath: string): string { + const normalized = sourcePath.replaceAll("\\", "/"); + const windowsMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (windowsMatch) { + const drive = windowsMatch[1]?.toUpperCase() ?? "UNKNOWN"; + const rest = windowsMatch[2] ?? ""; + return path.posix.join("windows", drive, rest); + } + if (normalized.startsWith("/")) { + return path.posix.join("posix", normalized.slice(1)); + } + return path.posix.join("relative", normalized); +} + +export function buildBackupArchivePath(archiveRoot: string, sourcePath: string): string { + return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath)); +} + +function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number { + const depthDelta = left.canonicalPath.length - right.canonicalPath.length; + if (depthDelta !== 0) { + return depthDelta; + } + const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.canonicalPath.localeCompare(right.canonicalPath); +} + +async function canonicalizeExistingPath(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +export async function resolveBackupPlanFromDisk( + params: { + includeWorkspace?: boolean; + onlyConfig?: boolean; + nowMs?: number; + } = {}, +): Promise { + const includeWorkspace = params.includeWorkspace ?? true; + const onlyConfig = params.onlyConfig ?? false; + const stateDir = resolveStateDir(); + const configPath = resolveConfigPath(); + const oauthDir = resolveOAuthDir(); + const archiveRoot = buildBackupArchiveRoot(params.nowMs); + + if (onlyConfig) { + const resolvedConfigPath = path.resolve(configPath); + if (!(await pathExists(resolvedConfigPath))) { + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: [], + included: [], + skipped: [ + { + kind: "config", + sourcePath: resolvedConfigPath, + displayPath: shortenHomePath(resolvedConfigPath), + reason: "missing", + }, + ], + }; + } + + const canonicalConfigPath = await canonicalizeExistingPath(resolvedConfigPath); + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: [], + included: [ + { + kind: "config", + sourcePath: canonicalConfigPath, + displayPath: shortenHomePath(canonicalConfigPath), + archivePath: buildBackupArchivePath(archiveRoot, canonicalConfigPath), + }, + ], + skipped: [], + }; + } + + const configSnapshot = await readConfigFileSnapshot(); + if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) { + throw new Error( + `Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`, + ); + } + const cleanupPlan = buildCleanupPlan({ + cfg: configSnapshot.config, + stateDir, + configPath, + oauthDir, + }); + const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : []; + + const rawCandidates: Array> = [ + { kind: "state", sourcePath: path.resolve(stateDir) }, + ...(cleanupPlan.configInsideState + ? [] + : [{ kind: "config" as const, sourcePath: path.resolve(configPath) }]), + ...(cleanupPlan.oauthInsideState + ? [] + : [{ kind: "credentials" as const, sourcePath: path.resolve(oauthDir) }]), + ...(includeWorkspace + ? workspaceDirs.map((workspaceDir) => ({ + kind: "workspace" as const, + sourcePath: path.resolve(workspaceDir), + })) + : []), + ]; + + const candidates: BackupAssetCandidate[] = await Promise.all( + rawCandidates.map(async (candidate) => { + const exists = await pathExists(candidate.sourcePath); + return { + ...candidate, + exists, + canonicalPath: exists + ? await canonicalizeExistingPath(candidate.sourcePath) + : path.resolve(candidate.sourcePath), + }; + }), + ); + + const uniqueCandidates: BackupAssetCandidate[] = []; + const seenCanonicalPaths = new Set(); + for (const candidate of [...candidates].toSorted(compareCandidates)) { + if (seenCanonicalPaths.has(candidate.canonicalPath)) { + continue; + } + seenCanonicalPaths.add(candidate.canonicalPath); + uniqueCandidates.push(candidate); + } + const included: BackupAsset[] = []; + const skipped: SkippedBackupAsset[] = []; + + for (const candidate of uniqueCandidates) { + if (!candidate.exists) { + skipped.push({ + kind: candidate.kind, + sourcePath: candidate.sourcePath, + displayPath: shortenHomePath(candidate.sourcePath), + reason: "missing", + }); + continue; + } + + const coveredBy = included.find((asset) => + isPathWithin(candidate.canonicalPath, asset.sourcePath), + ); + if (coveredBy) { + skipped.push({ + kind: candidate.kind, + sourcePath: candidate.canonicalPath, + displayPath: shortenHomePath(candidate.canonicalPath), + reason: "covered", + coveredBy: coveredBy.displayPath, + }); + continue; + } + + included.push({ + kind: candidate.kind, + sourcePath: candidate.canonicalPath, + displayPath: shortenHomePath(candidate.canonicalPath), + archivePath: buildBackupArchivePath(archiveRoot, candidate.canonicalPath), + }); + } + + return { + stateDir, + configPath, + oauthDir, + workspaceDirs: workspaceDirs.map((entry) => path.resolve(entry)), + included, + skipped, + }; +} diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts new file mode 100644 index 0000000000000..7723a3c46cdac --- /dev/null +++ b/src/commands/backup-verify.test.ts @@ -0,0 +1,274 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; +import { buildBackupArchiveRoot } from "./backup-shared.js"; +import { backupVerifyCommand } from "./backup-verify.js"; +import { backupCreateCommand } from "./backup.js"; + +describe("backupVerifyCommand", () => { + let tempHome: TempHomeEnv; + + beforeEach(async () => { + tempHome = await createTempHomeEnv("openclaw-backup-verify-test-"); + }); + + afterEach(async () => { + await tempHome.restore(); + }); + + it("verifies an archive created by backup create", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-verify-out-")); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "hello\n", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0); + const created = await backupCreateCommand(runtime, { output: archiveDir, nowMs }); + const verified = await backupVerifyCommand(runtime, { archive: created.archivePath }); + + expect(verified.ok).toBe(true); + expect(verified.archiveRoot).toBe(buildBackupArchiveRoot(nowMs)); + expect(verified.assetCount).toBeGreaterThan(0); + } finally { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("fails when the archive does not contain a manifest", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-no-manifest-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + try { + const root = path.join(tempDir, "root"); + await fs.mkdir(path.join(root, "payload"), { recursive: true }); + await fs.writeFile(path.join(root, "payload", "data.txt"), "x\n", "utf8"); + await tar.c({ file: archivePath, gzip: true, cwd: tempDir }, ["root"]); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /expected exactly one backup manifest entry/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("fails when the manifest references a missing asset payload", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-missing-asset-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const root = path.join(tempDir, rootName); + await fs.mkdir(root, { recursive: true }); + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: `${rootName}/payload/posix/tmp/.openclaw`, + }, + ], + }; + await fs.writeFile( + path.join(root, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + await tar.c({ file: archivePath, gzip: true, cwd: tempDir }, [rootName]); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /missing payload for manifest asset/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("fails when archive paths contain traversal segments", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-traversal-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const traversalPath = `${rootName}/payload/../escaped.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: traversalPath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = traversalPath; + } + }, + }, + [manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /path traversal segments/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("ignores payload manifest.json files when locating the backup manifest", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); + const configPath = path.join(tempHome.home, "custom-config.json"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-verify-out-")); + try { + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + defaults: { + workspace: externalWorkspace, + }, + }, + }), + "utf8", + ); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "hello\n", "utf8"); + await fs.writeFile( + path.join(externalWorkspace, "manifest.json"), + JSON.stringify({ name: "workspace-payload" }), + "utf8", + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const created = await backupCreateCommand(runtime, { + output: archiveDir, + includeWorkspace: true, + nowMs: Date.UTC(2026, 2, 9, 2, 0, 0), + }); + const verified = await backupVerifyCommand(runtime, { archive: created.archivePath }); + + expect(verified.ok).toBe(true); + expect(verified.assetCount).toBeGreaterThanOrEqual(2); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + await fs.rm(externalWorkspace, { recursive: true, force: true }); + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("fails when the archive contains duplicate root manifest entries", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-duplicate-manifest-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: `${rootName}/payload/posix/tmp/.openclaw/payload.txt`, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = `${rootName}/payload/posix/tmp/.openclaw/payload.txt`; + } + }, + }, + [manifestPath, manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /expected exactly one backup manifest entry, found 2/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts new file mode 100644 index 0000000000000..109955bdfb195 --- /dev/null +++ b/src/commands/backup-verify.ts @@ -0,0 +1,304 @@ +import path from "node:path"; +import * as tar from "tar"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; + +const WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE = /^[A-Za-z]:[\\/]/; + +type BackupManifestAsset = { + kind: string; + sourcePath: string; + archivePath: string; +}; + +type BackupManifest = { + schemaVersion: number; + createdAt: string; + archiveRoot: string; + runtimeVersion: string; + platform: string; + nodeVersion: string; + options?: { + includeWorkspace?: boolean; + }; + paths?: { + stateDir?: string; + configPath?: string; + oauthDir?: string; + workspaceDirs?: string[]; + }; + assets: BackupManifestAsset[]; + skipped?: Array<{ + kind?: string; + sourcePath?: string; + reason?: string; + coveredBy?: string; + }>; +}; + +export type BackupVerifyOptions = { + archive: string; + json?: boolean; +}; + +export type BackupVerifyResult = { + ok: true; + archivePath: string; + archiveRoot: string; + createdAt: string; + runtimeVersion: string; + assetCount: number; + entryCount: number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/u, ""); +} + +function normalizeArchivePath(entryPath: string, label: string): string { + const trimmed = stripTrailingSlashes(entryPath.trim()); + if (!trimmed) { + throw new Error(`${label} is empty.`); + } + if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) { + throw new Error(`${label} must be relative: ${entryPath}`); + } + if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error(`${label} contains path traversal segments: ${entryPath}`); + } + + const normalized = stripTrailingSlashes(path.posix.normalize(trimmed)); + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + throw new Error(`${label} resolves outside the archive root: ${entryPath}`); + } + return normalized; +} + +function normalizeArchiveRoot(rootName: string): string { + const normalized = normalizeArchivePath(rootName, "Backup manifest archiveRoot"); + if (normalized.includes("/")) { + throw new Error(`Backup manifest archiveRoot must be a single path segment: ${rootName}`); + } + return normalized; +} + +function isArchivePathWithin(child: string, parent: string): boolean { + const relative = path.posix.relative(parent, child); + return relative === "" || (!relative.startsWith("../") && relative !== ".."); +} + +function parseManifest(raw: string): BackupManifest { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Backup manifest is not valid JSON: ${String(err)}`, { cause: err }); + } + + if (!isRecord(parsed)) { + throw new Error("Backup manifest must be an object."); + } + if (parsed.schemaVersion !== 1) { + throw new Error(`Unsupported backup manifest schemaVersion: ${String(parsed.schemaVersion)}`); + } + if (typeof parsed.archiveRoot !== "string" || !parsed.archiveRoot.trim()) { + throw new Error("Backup manifest is missing archiveRoot."); + } + if (typeof parsed.createdAt !== "string" || !parsed.createdAt.trim()) { + throw new Error("Backup manifest is missing createdAt."); + } + if (!Array.isArray(parsed.assets)) { + throw new Error("Backup manifest is missing assets."); + } + + const assets: BackupManifestAsset[] = []; + for (const asset of parsed.assets) { + if (!isRecord(asset)) { + throw new Error("Backup manifest contains a non-object asset."); + } + if (typeof asset.kind !== "string" || !asset.kind.trim()) { + throw new Error("Backup manifest asset is missing kind."); + } + if (typeof asset.sourcePath !== "string" || !asset.sourcePath.trim()) { + throw new Error("Backup manifest asset is missing sourcePath."); + } + if (typeof asset.archivePath !== "string" || !asset.archivePath.trim()) { + throw new Error("Backup manifest asset is missing archivePath."); + } + assets.push({ + kind: asset.kind, + sourcePath: asset.sourcePath, + archivePath: asset.archivePath, + }); + } + + return { + schemaVersion: 1, + archiveRoot: parsed.archiveRoot, + createdAt: parsed.createdAt, + runtimeVersion: + typeof parsed.runtimeVersion === "string" && parsed.runtimeVersion.trim() + ? parsed.runtimeVersion + : "unknown", + platform: typeof parsed.platform === "string" ? parsed.platform : "unknown", + nodeVersion: typeof parsed.nodeVersion === "string" ? parsed.nodeVersion : "unknown", + options: isRecord(parsed.options) + ? { includeWorkspace: parsed.options.includeWorkspace as boolean | undefined } + : undefined, + paths: isRecord(parsed.paths) + ? { + stateDir: typeof parsed.paths.stateDir === "string" ? parsed.paths.stateDir : undefined, + configPath: + typeof parsed.paths.configPath === "string" ? parsed.paths.configPath : undefined, + oauthDir: typeof parsed.paths.oauthDir === "string" ? parsed.paths.oauthDir : undefined, + workspaceDirs: Array.isArray(parsed.paths.workspaceDirs) + ? parsed.paths.workspaceDirs.filter( + (entry): entry is string => typeof entry === "string", + ) + : undefined, + } + : undefined, + assets, + skipped: Array.isArray(parsed.skipped) ? parsed.skipped : undefined, + }; +} + +async function listArchiveEntries(archivePath: string): Promise { + const entries: string[] = []; + await tar.t({ + file: archivePath, + gzip: true, + onentry: (entry) => { + entries.push(entry.path); + }, + }); + return entries; +} + +async function extractManifest(params: { + archivePath: string; + manifestEntryPath: string; +}): Promise { + let manifestContentPromise: Promise | undefined; + await tar.t({ + file: params.archivePath, + gzip: true, + onentry: (entry) => { + if (entry.path !== params.manifestEntryPath) { + entry.resume(); + return; + } + + manifestContentPromise = new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + entry.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + entry.on("error", reject); + entry.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + }); + }, + }); + + if (!manifestContentPromise) { + throw new Error(`Archive is missing manifest entry: ${params.manifestEntryPath}`); + } + return await manifestContentPromise; +} + +function isRootManifestEntry(entryPath: string): boolean { + const parts = entryPath.split("/"); + return parts.length === 2 && parts[0] !== "" && parts[1] === "manifest.json"; +} + +function verifyManifestAgainstEntries(manifest: BackupManifest, entries: Set): void { + const archiveRoot = normalizeArchiveRoot(manifest.archiveRoot); + const manifestEntryPath = path.posix.join(archiveRoot, "manifest.json"); + const normalizedEntries = [...entries]; + const normalizedEntrySet = new Set(normalizedEntries); + + if (!normalizedEntrySet.has(manifestEntryPath)) { + throw new Error(`Archive is missing manifest entry: ${manifestEntryPath}`); + } + + for (const entry of normalizedEntries) { + if (!isArchivePathWithin(entry, archiveRoot)) { + throw new Error(`Archive entry is outside the declared archive root: ${entry}`); + } + } + + const payloadRoot = path.posix.join(archiveRoot, "payload"); + for (const asset of manifest.assets) { + const assetArchivePath = normalizeArchivePath(asset.archivePath, "Backup manifest asset path"); + if (!isArchivePathWithin(assetArchivePath, payloadRoot)) { + throw new Error(`Manifest asset path is outside payload root: ${asset.archivePath}`); + } + const exact = normalizedEntrySet.has(assetArchivePath); + const nested = normalizedEntries.some( + (entry) => entry !== assetArchivePath && isArchivePathWithin(entry, assetArchivePath), + ); + if (!exact && !nested) { + throw new Error(`Archive is missing payload for manifest asset: ${assetArchivePath}`); + } + } +} + +function formatResult(result: BackupVerifyResult): string { + return [ + `Backup archive OK: ${result.archivePath}`, + `Archive root: ${result.archiveRoot}`, + `Created at: ${result.createdAt}`, + `Runtime version: ${result.runtimeVersion}`, + `Assets verified: ${result.assetCount}`, + `Archive entries scanned: ${result.entryCount}`, + ].join("\n"); +} + +export async function backupVerifyCommand( + runtime: RuntimeEnv, + opts: BackupVerifyOptions, +): Promise { + const archivePath = resolveUserPath(opts.archive); + const rawEntries = await listArchiveEntries(archivePath); + if (rawEntries.length === 0) { + throw new Error("Backup archive is empty."); + } + + const entries = rawEntries.map((entry) => ({ + raw: entry, + normalized: normalizeArchivePath(entry, "Archive entry"), + })); + const normalizedEntrySet = new Set(entries.map((entry) => entry.normalized)); + + const manifestMatches = entries.filter((entry) => isRootManifestEntry(entry.normalized)); + if (manifestMatches.length !== 1) { + throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`); + } + const manifestEntryPath = manifestMatches[0]?.raw; + if (!manifestEntryPath) { + throw new Error("Backup archive manifest entry could not be resolved."); + } + + const manifestRaw = await extractManifest({ archivePath, manifestEntryPath }); + const manifest = parseManifest(manifestRaw); + verifyManifestAgainstEntries(manifest, normalizedEntrySet); + + const result: BackupVerifyResult = { + ok: true, + archivePath, + archiveRoot: manifest.archiveRoot, + createdAt: manifest.createdAt, + runtimeVersion: manifest.runtimeVersion, + assetCount: manifest.assets.length, + entryCount: rawEntries.length, + }; + + runtime.log(opts.json ? JSON.stringify(result, null, 2) : formatResult(result)); + return result; +} diff --git a/src/commands/backup.atomic.test.ts b/src/commands/backup.atomic.test.ts new file mode 100644 index 0000000000000..53303ef53fe74 --- /dev/null +++ b/src/commands/backup.atomic.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; + +const tarCreateMock = vi.hoisted(() => vi.fn()); +const backupVerifyCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("tar", () => ({ + c: tarCreateMock, +})); + +vi.mock("./backup-verify.js", () => ({ + backupVerifyCommand: backupVerifyCommandMock, +})); + +const { backupCreateCommand } = await import("./backup.js"); + +describe("backupCreateCommand atomic archive write", () => { + let tempHome: TempHomeEnv; + + beforeEach(async () => { + tempHome = await createTempHomeEnv("openclaw-backup-atomic-test-"); + tarCreateMock.mockReset(); + backupVerifyCommandMock.mockReset(); + }); + + afterEach(async () => { + await tempHome.restore(); + }); + + it("does not leave a partial final archive behind when tar creation fails", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-failure-")); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + tarCreateMock.mockRejectedValueOnce(new Error("disk full")); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const outputPath = path.join(archiveDir, "backup.tar.gz"); + + await expect( + backupCreateCommand(runtime, { + output: outputPath, + }), + ).rejects.toThrow(/disk full/i); + + await expect(fs.access(outputPath)).rejects.toThrow(); + const remaining = await fs.readdir(archiveDir); + expect(remaining).toEqual([]); + } finally { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("does not overwrite an archive created after readiness checks complete", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-race-")); + const realLink = fs.link.bind(fs); + const linkSpy = vi.spyOn(fs, "link"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => { + await fs.writeFile(file, "archive-bytes", "utf8"); + }); + linkSpy.mockImplementationOnce(async (existingPath, newPath) => { + await fs.writeFile(newPath, "concurrent-archive", "utf8"); + return await realLink(existingPath, newPath); + }); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const outputPath = path.join(archiveDir, "backup.tar.gz"); + + await expect( + backupCreateCommand(runtime, { + output: outputPath, + }), + ).rejects.toThrow(/refusing to overwrite existing backup archive/i); + + expect(await fs.readFile(outputPath, "utf8")).toBe("concurrent-archive"); + } finally { + linkSpy.mockRestore(); + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("falls back to exclusive copy when hard-link publication is unsupported", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-copy-fallback-")); + const linkSpy = vi.spyOn(fs, "link"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + tarCreateMock.mockImplementationOnce(async ({ file }: { file: string }) => { + await fs.writeFile(file, "archive-bytes", "utf8"); + }); + linkSpy.mockRejectedValueOnce( + Object.assign(new Error("hard links not supported"), { code: "EOPNOTSUPP" }), + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const outputPath = path.join(archiveDir, "backup.tar.gz"); + + const result = await backupCreateCommand(runtime, { + output: outputPath, + }); + + expect(result.archivePath).toBe(outputPath); + expect(await fs.readFile(outputPath, "utf8")).toBe("archive-bytes"); + } finally { + linkSpy.mockRestore(); + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts new file mode 100644 index 0000000000000..349714e4d1561 --- /dev/null +++ b/src/commands/backup.test.ts @@ -0,0 +1,434 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"; +import { + buildBackupArchiveRoot, + encodeAbsolutePathForBackupArchive, + resolveBackupPlanFromDisk, +} from "./backup-shared.js"; +import { backupCreateCommand } from "./backup.js"; + +const backupVerifyCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("./backup-verify.js", () => ({ + backupVerifyCommand: backupVerifyCommandMock, +})); + +describe("backup commands", () => { + let tempHome: TempHomeEnv; + let previousCwd: string; + + beforeEach(async () => { + tempHome = await createTempHomeEnv("openclaw-backup-test-"); + previousCwd = process.cwd(); + backupVerifyCommandMock.mockReset(); + backupVerifyCommandMock.mockResolvedValue({ + ok: true, + archivePath: "/tmp/fake.tar.gz", + archiveRoot: "fake", + createdAt: new Date().toISOString(), + runtimeVersion: "test", + assetCount: 1, + entryCount: 2, + }); + }); + + afterEach(async () => { + process.chdir(previousCwd); + await tempHome.restore(); + }); + + it("collapses default config, credentials, and workspace into the state backup root", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); + await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "workspace", "SOUL.md"), "# soul\n", "utf8"); + + const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 }); + + expect(plan.included).toHaveLength(1); + expect(plan.included[0]?.kind).toBe("state"); + expect(plan.skipped).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), + ); + }); + + it("orders coverage checks by canonical path so symlinked workspaces do not duplicate state", async () => { + if (process.platform === "win32") { + return; + } + + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-link-")); + const workspaceLink = path.join(symlinkDir, "ws-link"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); + await fs.symlink(workspaceDir, workspaceLink); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + agents: { + defaults: { + workspace: workspaceLink, + }, + }, + }), + "utf8", + ); + + const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 }); + + expect(plan.included).toHaveLength(1); + expect(plan.included[0]?.kind).toBe("state"); + expect(plan.skipped).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]), + ); + } finally { + await fs.rm(symlinkDir, { recursive: true, force: true }); + } + }); + + it("creates an archive with a manifest and external workspace payload", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); + const configPath = path.join(tempHome.home, "custom-config.json"); + const backupDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backups-")); + try { + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile( + configPath, + JSON.stringify({ + agents: { + defaults: { + workspace: externalWorkspace, + }, + }, + }), + "utf8", + ); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + await fs.writeFile(path.join(externalWorkspace, "SOUL.md"), "# external\n", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0); + const result = await backupCreateCommand(runtime, { + output: backupDir, + includeWorkspace: true, + nowMs, + }); + + expect(result.archivePath).toBe( + path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), + ); + + const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-extract-")); + try { + await tar.x({ file: result.archivePath, cwd: extractDir, gzip: true }); + const archiveRoot = path.join(extractDir, buildBackupArchiveRoot(nowMs)); + const manifest = JSON.parse( + await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8"), + ) as { + assets: Array<{ kind: string; archivePath: string }>; + }; + + expect(manifest.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "state" }), + expect.objectContaining({ kind: "config" }), + expect.objectContaining({ kind: "workspace" }), + ]), + ); + + const stateAsset = result.assets.find((asset) => asset.kind === "state"); + const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace"); + expect(stateAsset).toBeDefined(); + expect(workspaceAsset).toBeDefined(); + + const encodedStatePath = path.join( + archiveRoot, + "payload", + encodeAbsolutePathForBackupArchive(stateAsset!.sourcePath), + "state.txt", + ); + const encodedWorkspacePath = path.join( + archiveRoot, + "payload", + encodeAbsolutePathForBackupArchive(workspaceAsset!.sourcePath), + "SOUL.md", + ); + expect(await fs.readFile(encodedStatePath, "utf8")).toBe("state\n"); + expect(await fs.readFile(encodedWorkspacePath, "utf8")).toBe("# external\n"); + } finally { + await fs.rm(extractDir, { recursive: true, force: true }); + } + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + await fs.rm(externalWorkspace, { recursive: true, force: true }); + await fs.rm(backupDir, { recursive: true, force: true }); + } + }); + + it("optionally verifies the archive after writing it", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const archiveDir = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-backup-verify-on-create-"), + ); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await backupCreateCommand(runtime, { + output: archiveDir, + verify: true, + }); + + expect(result.verified).toBe(true); + expect(backupVerifyCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ log: expect.any(Function) }), + expect.objectContaining({ archive: result.archivePath, json: false }), + ); + } finally { + await fs.rm(archiveDir, { recursive: true, force: true }); + } + }); + + it("rejects output paths that would be created inside a backed-up directory", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect( + backupCreateCommand(runtime, { + output: path.join(stateDir, "backups"), + }), + ).rejects.toThrow(/must not be written inside a source path/i); + }); + + it("rejects symlinked output paths even when intermediate directories do not exist yet", async () => { + if (process.platform === "win32") { + return; + } + + const stateDir = path.join(tempHome.home, ".openclaw"); + const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-link-")); + const symlinkPath = path.join(symlinkDir, "linked-state"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.symlink(stateDir, symlinkPath); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect( + backupCreateCommand(runtime, { + output: path.join(symlinkPath, "new", "subdir", "backup.tar.gz"), + }), + ).rejects.toThrow(/must not be written inside a source path/i); + } finally { + await fs.rm(symlinkDir, { recursive: true, force: true }); + } + }); + + it("falls back to the home directory when cwd is inside a backed-up source tree", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); + process.chdir(workspaceDir); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 1, 2, 3); + const result = await backupCreateCommand(runtime, { nowMs }); + + expect(result.archivePath).toBe( + path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), + ); + await fs.rm(result.archivePath, { force: true }); + }); + + it("falls back to the home directory when cwd is a symlink into a backed-up source tree", async () => { + if (process.platform === "win32") { + return; + } + + const stateDir = path.join(tempHome.home, ".openclaw"); + const workspaceDir = path.join(stateDir, "workspace"); + const linkParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cwd-link-")); + const workspaceLink = path.join(linkParent, "workspace-link"); + try { + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); + await fs.symlink(workspaceDir, workspaceLink); + process.chdir(workspaceLink); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const nowMs = Date.UTC(2026, 2, 9, 1, 3, 4); + const result = await backupCreateCommand(runtime, { nowMs }); + + expect(result.archivePath).toBe( + path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`), + ); + await fs.rm(result.archivePath, { force: true }); + } finally { + await fs.rm(linkParent, { recursive: true, force: true }); + } + }); + + it("allows dry-run preview even when the target archive already exists", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const existingArchive = path.join(tempHome.home, "existing-backup.tar.gz"); + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(existingArchive, "already here", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await backupCreateCommand(runtime, { + output: existingArchive, + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.verified).toBe(false); + expect(result.archivePath).toBe(existingArchive); + expect(await fs.readFile(existingArchive, "utf8")).toBe("already here"); + }); + + it("fails fast when config is invalid and workspace backup is enabled", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const configPath = path.join(tempHome.home, "custom-config.json"); + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + try { + await expect(backupCreateCommand(runtime, { dryRun: true })).rejects.toThrow( + /--no-include-workspace/i, + ); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); + + it("allows explicit partial backups when config is invalid", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const configPath = path.join(tempHome.home, "custom-config.json"); + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); + await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + try { + const result = await backupCreateCommand(runtime, { + dryRun: true, + includeWorkspace: false, + }); + + expect(result.includeWorkspace).toBe(false); + expect(result.assets.some((asset) => asset.kind === "workspace")).toBe(false); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); + + it("backs up only the active config file when --only-config is requested", async () => { + const stateDir = path.join(tempHome.home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ theme: "config-only" }), "utf8"); + await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8"); + await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await backupCreateCommand(runtime, { + dryRun: true, + onlyConfig: true, + }); + + expect(result.onlyConfig).toBe(true); + expect(result.includeWorkspace).toBe(false); + expect(result.assets).toHaveLength(1); + expect(result.assets[0]?.kind).toBe("config"); + }); + + it("allows config-only backups even when the config file is invalid", async () => { + const configPath = path.join(tempHome.home, "custom-config.json"); + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8"); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + try { + const result = await backupCreateCommand(runtime, { + dryRun: true, + onlyConfig: true, + }); + + expect(result.assets).toHaveLength(1); + expect(result.assets[0]?.kind).toBe("config"); + } finally { + delete process.env.OPENCLAW_CONFIG_PATH; + } + }); +}); diff --git a/src/commands/backup.ts b/src/commands/backup.ts new file mode 100644 index 0000000000000..15f0f505d7661 --- /dev/null +++ b/src/commands/backup.ts @@ -0,0 +1,382 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as tar from "tar"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveHomeDir, resolveUserPath } from "../utils.js"; +import { resolveRuntimeServiceVersion } from "../version.js"; +import { + buildBackupArchiveBasename, + buildBackupArchiveRoot, + buildBackupArchivePath, + type BackupAsset, + resolveBackupPlanFromDisk, +} from "./backup-shared.js"; +import { backupVerifyCommand } from "./backup-verify.js"; +import { isPathWithin } from "./cleanup-utils.js"; + +export type BackupCreateOptions = { + output?: string; + dryRun?: boolean; + includeWorkspace?: boolean; + onlyConfig?: boolean; + verify?: boolean; + json?: boolean; + nowMs?: number; +}; + +type BackupManifestAsset = { + kind: BackupAsset["kind"]; + sourcePath: string; + archivePath: string; +}; + +type BackupManifest = { + schemaVersion: 1; + createdAt: string; + archiveRoot: string; + runtimeVersion: string; + platform: NodeJS.Platform; + nodeVersion: string; + options: { + includeWorkspace: boolean; + onlyConfig?: boolean; + }; + paths: { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; + }; + assets: BackupManifestAsset[]; + skipped: Array<{ + kind: string; + sourcePath: string; + reason: string; + coveredBy?: string; + }>; +}; + +export type BackupCreateResult = { + createdAt: string; + archiveRoot: string; + archivePath: string; + dryRun: boolean; + includeWorkspace: boolean; + onlyConfig: boolean; + verified: boolean; + assets: BackupAsset[]; + skipped: Array<{ + kind: string; + sourcePath: string; + displayPath: string; + reason: string; + coveredBy?: string; + }>; +}; + +async function resolveOutputPath(params: { + output?: string; + nowMs: number; + includedAssets: BackupAsset[]; + stateDir: string; +}): Promise { + const basename = buildBackupArchiveBasename(params.nowMs); + const rawOutput = params.output?.trim(); + if (!rawOutput) { + const cwd = path.resolve(process.cwd()); + const canonicalCwd = await fs.realpath(cwd).catch(() => cwd); + const cwdInsideSource = params.includedAssets.some((asset) => + isPathWithin(canonicalCwd, asset.sourcePath), + ); + const defaultDir = cwdInsideSource ? (resolveHomeDir() ?? path.dirname(params.stateDir)) : cwd; + return path.resolve(defaultDir, basename); + } + + const resolved = resolveUserPath(rawOutput); + if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) { + return path.join(resolved, basename); + } + + try { + const stat = await fs.stat(resolved); + if (stat.isDirectory()) { + return path.join(resolved, basename); + } + } catch { + // Treat as a file path when the target does not exist yet. + } + + return resolved; +} + +async function assertOutputPathReady(outputPath: string): Promise { + try { + await fs.access(outputPath); + throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return; + } + throw err; + } +} + +function buildTempArchivePath(outputPath: string): string { + return `${outputPath}.${randomUUID()}.tmp`; +} + +function isLinkUnsupportedError(code: string | undefined): boolean { + return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM"; +} + +async function publishTempArchive(params: { + tempArchivePath: string; + outputPath: string; +}): Promise { + try { + await fs.link(params.tempArchivePath, params.outputPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if (code === "EEXIST") { + throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { + cause: err, + }); + } + if (!isLinkUnsupportedError(code)) { + throw err; + } + + try { + // Some backup targets support ordinary files but not hard links. + await fs.copyFile(params.tempArchivePath, params.outputPath, fsConstants.COPYFILE_EXCL); + } catch (copyErr) { + const copyCode = (copyErr as NodeJS.ErrnoException | undefined)?.code; + if (copyCode !== "EEXIST") { + await fs.rm(params.outputPath, { force: true }).catch(() => undefined); + } + if (copyCode === "EEXIST") { + throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { + cause: copyErr, + }); + } + throw copyErr; + } + } + await fs.rm(params.tempArchivePath, { force: true }); +} + +async function canonicalizePathForContainment(targetPath: string): Promise { + const resolved = path.resolve(targetPath); + const suffix: string[] = []; + let probe = resolved; + + while (true) { + try { + const realProbe = await fs.realpath(probe); + return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed()); + } catch { + const parent = path.dirname(probe); + if (parent === probe) { + return resolved; + } + suffix.push(path.basename(probe)); + probe = parent; + } + } +} + +function buildManifest(params: { + createdAt: string; + archiveRoot: string; + includeWorkspace: boolean; + onlyConfig: boolean; + assets: BackupAsset[]; + skipped: BackupCreateResult["skipped"]; + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: string[]; +}): BackupManifest { + return { + schemaVersion: 1, + createdAt: params.createdAt, + archiveRoot: params.archiveRoot, + runtimeVersion: resolveRuntimeServiceVersion(), + platform: process.platform, + nodeVersion: process.version, + options: { + includeWorkspace: params.includeWorkspace, + onlyConfig: params.onlyConfig, + }, + paths: { + stateDir: params.stateDir, + configPath: params.configPath, + oauthDir: params.oauthDir, + workspaceDirs: params.workspaceDirs, + }, + assets: params.assets.map((asset) => ({ + kind: asset.kind, + sourcePath: asset.sourcePath, + archivePath: asset.archivePath, + })), + skipped: params.skipped.map((entry) => ({ + kind: entry.kind, + sourcePath: entry.sourcePath, + reason: entry.reason, + coveredBy: entry.coveredBy, + })), + }; +} + +function formatTextSummary(result: BackupCreateResult): string[] { + const lines = [`Backup archive: ${result.archivePath}`]; + lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`); + for (const asset of result.assets) { + lines.push(`- ${asset.kind}: ${asset.displayPath}`); + } + if (result.skipped.length > 0) { + lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`); + for (const entry of result.skipped) { + if (entry.reason === "covered" && entry.coveredBy) { + lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`); + } else { + lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`); + } + } + } + if (result.dryRun) { + lines.push("Dry run only; archive was not written."); + } else { + lines.push(`Created ${result.archivePath}`); + if (result.verified) { + lines.push("Archive verification: passed"); + } + } + return lines; +} + +function remapArchiveEntryPath(params: { + entryPath: string; + manifestPath: string; + archiveRoot: string; +}): string { + const normalizedEntry = path.resolve(params.entryPath); + if (normalizedEntry === params.manifestPath) { + return path.posix.join(params.archiveRoot, "manifest.json"); + } + return buildBackupArchivePath(params.archiveRoot, normalizedEntry); +} + +export async function backupCreateCommand( + runtime: RuntimeEnv, + opts: BackupCreateOptions = {}, +): Promise { + const nowMs = opts.nowMs ?? Date.now(); + const archiveRoot = buildBackupArchiveRoot(nowMs); + const onlyConfig = Boolean(opts.onlyConfig); + const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); + const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); + const outputPath = await resolveOutputPath({ + output: opts.output, + nowMs, + includedAssets: plan.included, + stateDir: plan.stateDir, + }); + + if (plan.included.length === 0) { + throw new Error( + onlyConfig + ? "No OpenClaw config file was found to back up." + : "No local OpenClaw state was found to back up.", + ); + } + + const canonicalOutputPath = await canonicalizePathForContainment(outputPath); + const overlappingAsset = plan.included.find((asset) => + isPathWithin(canonicalOutputPath, asset.sourcePath), + ); + if (overlappingAsset) { + throw new Error( + `Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`, + ); + } + + if (!opts.dryRun) { + await assertOutputPathReady(outputPath); + } + + const createdAt = new Date(nowMs).toISOString(); + const result: BackupCreateResult = { + createdAt, + archiveRoot, + archivePath: outputPath, + dryRun: Boolean(opts.dryRun), + includeWorkspace, + onlyConfig, + verified: false, + assets: plan.included, + skipped: plan.skipped, + }; + + if (!opts.dryRun) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-")); + const manifestPath = path.join(tempDir, "manifest.json"); + const tempArchivePath = buildTempArchivePath(outputPath); + try { + const manifest = buildManifest({ + createdAt, + archiveRoot, + includeWorkspace, + onlyConfig, + assets: result.assets, + skipped: result.skipped, + stateDir: plan.stateDir, + configPath: plan.configPath, + oauthDir: plan.oauthDir, + workspaceDirs: plan.workspaceDirs, + }); + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + await tar.c( + { + file: tempArchivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + entry.path = remapArchiveEntryPath({ + entryPath: entry.path, + manifestPath, + archiveRoot, + }); + }, + }, + [manifestPath, ...result.assets.map((asset) => asset.sourcePath)], + ); + await publishTempArchive({ tempArchivePath, outputPath }); + } finally { + await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + + if (opts.verify) { + await backupVerifyCommand( + { + ...runtime, + log: () => {}, + }, + { archive: outputPath, json: false }, + ); + result.verified = true; + } + } + + const output = opts.json ? JSON.stringify(result, null, 2) : formatTextSummary(result).join("\n"); + runtime.log(output); + return result; +} diff --git a/src/commands/reset.test.ts b/src/commands/reset.test.ts new file mode 100644 index 0000000000000..b97545a43714a --- /dev/null +++ b/src/commands/reset.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createNonExitingRuntime } from "../runtime.js"; + +const resolveCleanupPlanFromDisk = vi.fn(); +const removePath = vi.fn(); +const listAgentSessionDirs = vi.fn(); +const removeStateAndLinkedPaths = vi.fn(); +const removeWorkspaceDirs = vi.fn(); + +vi.mock("../config/config.js", () => ({ + isNixMode: false, +})); + +vi.mock("./cleanup-plan.js", () => ({ + resolveCleanupPlanFromDisk, +})); + +vi.mock("./cleanup-utils.js", () => ({ + removePath, + listAgentSessionDirs, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +})); + +const { resetCommand } = await import("./reset.js"); + +describe("resetCommand", () => { + const runtime = createNonExitingRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveCleanupPlanFromDisk.mockReturnValue({ + stateDir: "/tmp/.openclaw", + configPath: "/tmp/.openclaw/openclaw.json", + oauthDir: "/tmp/.openclaw/credentials", + configInsideState: true, + oauthInsideState: true, + workspaceDirs: ["/tmp/.openclaw/workspace"], + }); + removePath.mockResolvedValue({ ok: true }); + listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]); + removeStateAndLinkedPaths.mockResolvedValue(undefined); + removeWorkspaceDirs.mockResolvedValue(undefined); + vi.spyOn(runtime, "log").mockImplementation(() => {}); + vi.spyOn(runtime, "error").mockImplementation(() => {}); + }); + + it("recommends creating a backup before state-destructive reset scopes", async () => { + await resetCommand(runtime, { + scope: "config+creds+sessions", + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); + + it("does not recommend backup for config-only reset", async () => { + await resetCommand(runtime, { + scope: "config", + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); +}); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 1f9ba9a79974a..596d80a139a61 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -44,6 +44,10 @@ async function stopGatewayIfRunning(runtime: RuntimeEnv) { } } +function logBackupRecommendation(runtime: RuntimeEnv) { + runtime.log(`Recommended first: ${formatCliCommand("openclaw backup create")}`); +} + export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { const interactive = !opts.nonInteractive; if (!interactive && !opts.yes) { @@ -110,6 +114,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { resolveCleanupPlanFromDisk(); if (scope !== "config") { + logBackupRecommendation(runtime); if (dryRun) { runtime.log("[dry-run] stop gateway service"); } else { diff --git a/src/commands/uninstall.test.ts b/src/commands/uninstall.test.ts new file mode 100644 index 0000000000000..bdf0efe1354aa --- /dev/null +++ b/src/commands/uninstall.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createNonExitingRuntime } from "../runtime.js"; + +const resolveCleanupPlanFromDisk = vi.fn(); +const removePath = vi.fn(); +const removeStateAndLinkedPaths = vi.fn(); +const removeWorkspaceDirs = vi.fn(); + +vi.mock("../config/config.js", () => ({ + isNixMode: false, +})); + +vi.mock("./cleanup-plan.js", () => ({ + resolveCleanupPlanFromDisk, +})); + +vi.mock("./cleanup-utils.js", () => ({ + removePath, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +})); + +const { uninstallCommand } = await import("./uninstall.js"); + +describe("uninstallCommand", () => { + const runtime = createNonExitingRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveCleanupPlanFromDisk.mockReturnValue({ + stateDir: "/tmp/.openclaw", + configPath: "/tmp/.openclaw/openclaw.json", + oauthDir: "/tmp/.openclaw/credentials", + configInsideState: true, + oauthInsideState: true, + workspaceDirs: ["/tmp/.openclaw/workspace"], + }); + removePath.mockResolvedValue({ ok: true }); + removeStateAndLinkedPaths.mockResolvedValue(undefined); + removeWorkspaceDirs.mockResolvedValue(undefined); + vi.spyOn(runtime, "log").mockImplementation(() => {}); + vi.spyOn(runtime, "error").mockImplementation(() => {}); + }); + + it("recommends creating a backup before removing state or workspaces", async () => { + await uninstallCommand(runtime, { + state: true, + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); + + it("does not recommend backup for service-only uninstall", async () => { + await uninstallCommand(runtime, { + service: true, + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); +}); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index aa91a321d00dc..5f03eb1cefa51 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { cancel, confirm, isCancel, multiselect } from "@clack/prompts"; +import { formatCliCommand } from "../cli/command-format.js"; import { isNixMode } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -92,6 +93,10 @@ async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) { }); } +function logBackupRecommendation(runtime: RuntimeEnv) { + runtime.log(`Recommended first: ${formatCliCommand("openclaw backup create")}`); +} + export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptions) { const { scopes, hadExplicit } = buildScopeSelection(opts); const interactive = !opts.nonInteractive; @@ -155,6 +160,10 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } = resolveCleanupPlanFromDisk(); + if (scopes.has("state") || scopes.has("workspace")) { + logBackupRecommendation(runtime); + } + if (scopes.has("service")) { if (dryRun) { runtime.log("[dry-run] remove gateway service"); From dadd7f99cd3b277eba150bd0a7240b3f1be69d7c Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 8 Mar 2026 22:11:38 +0200 Subject: [PATCH 162/311] fix(ci): scope secrets scan to branch changes --- .github/workflows/ci.yml | 6 ++++++ .pre-commit-config.yaml | 2 ++ .secrets.baseline | 9 +++++---- .../OpenClawIPCTests/AppStateRemoteConfigTests.swift | 12 ++++++------ .../models-config.applies-config-env-vars.test.ts | 6 +++--- src/agents/models-config.providers.matrix.test.ts | 6 +++--- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 872228e006f82..1d248d5c80462 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,6 +267,12 @@ jobs: with: submodules: false + - name: Ensure secrets base commit + uses: ./.github/actions/ensure-base-commit + with: + base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }} + - name: Setup Node environment uses: ./.github/actions/setup-node-env with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95421eb071ded..74dc847d48716 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,6 +69,8 @@ repos: - '"ap[i]Key": "xxxxx"(,)?' - --exclude-lines - 'ap[i]Key: "A[I]za\.\.\.",' + - --exclude-lines + - '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?' # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 diff --git a/.secrets.baseline b/.secrets.baseline index be62e5a4ca3b2..72adb0685b0ca 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -152,7 +152,8 @@ "grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'", "env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},", "\"ap[i]Key\": \"xxxxx\"(,)?", - "ap[i]Key: \"A[I]za\\.\\.\\.\"," + "ap[i]Key: \"A[I]za\\.\\.\\.\",", + "\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?" ] }, { @@ -11515,14 +11516,14 @@ "filename": "src/agents/models-config.providers.nvidia.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 13 + "line_number": 14 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.providers.nvidia.test.ts", "hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd", "is_verified": false, - "line_number": 22 + "line_number": 23 } ], "src/agents/models-config.providers.ollama.e2e.test.ts": [ @@ -13034,5 +13035,5 @@ } ] }, - "generated_at": "2026-03-08T18:30:57Z" + "generated_at": "2026-03-08T20:08:19Z" } diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index 172dc7ffc552f..16fb5eed1a06b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -43,7 +43,7 @@ struct AppStateRemoteConfigTests { "transport": "direct", "url": "wss://old-gateway.example", "token": [ - "$secretRef": "gateway-token", + "$secretRef": "gateway-token", // pragma: allowlist secret ], ], ], @@ -59,7 +59,7 @@ struct AppStateRemoteConfigTests { remoteToken: "", remoteTokenDirty: false) let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] - #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") + #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret let localRoot = AppState._testSyncedGatewayRoot( currentRoot: sshRoot, @@ -73,7 +73,7 @@ struct AppStateRemoteConfigTests { let localGateway = localRoot["gateway"] as? [String: Any] let localRemote = localGateway?["remote"] as? [String: Any] #expect(localGateway?["mode"] as? String == "local") - #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") + #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret } @Test @@ -81,7 +81,7 @@ struct AppStateRemoteConfigTests { let remote = AppState._testUpdatedRemoteGatewayConfig( current: [ "token": [ - "$secretRef": "gateway-token", + "$secretRef": "gateway-token", // pragma: allowlist secret ], ], transport: .direct, @@ -99,7 +99,7 @@ struct AppStateRemoteConfigTests { func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() { let current: [String: Any] = [ "token": [ - "$secretRef": "gateway-token", + "$secretRef": "gateway-token", // pragma: allowlist secret ], ] @@ -112,7 +112,7 @@ struct AppStateRemoteConfigTests { remoteIdentity: "", remoteToken: "", remoteTokenDirty: false) - #expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") + #expect((preserved["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret let cleared = AppState._testUpdatedRemoteGatewayConfig( current: current, diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index fa4adb861684d..4de78975cdb5c 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -22,7 +22,7 @@ describe("models-config", () => { models: { providers: {} }, env: { vars: { - OPENROUTER_API_KEY: "from-config", + OPENROUTER_API_KEY: "from-config", // pragma: allowlist secret [TEST_ENV_VAR]: "from-config", }, }, @@ -44,13 +44,13 @@ describe("models-config", () => { it("does not overwrite already-set host env vars while ensuring models.json", async () => { await withTempHome(async () => { await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => { - process.env.OPENROUTER_API_KEY = "from-host"; + process.env.OPENROUTER_API_KEY = "from-host"; // pragma: allowlist secret process.env[TEST_ENV_VAR] = "from-host"; const cfg: OpenClawConfig = { models: { providers: {} }, env: { vars: { - OPENROUTER_API_KEY: "from-config", + OPENROUTER_API_KEY: "from-config", // pragma: allowlist secret [TEST_ENV_VAR]: "from-config", }, }, diff --git a/src/agents/models-config.providers.matrix.test.ts b/src/agents/models-config.providers.matrix.test.ts index ec2b743afb68b..942cb68ab35ad 100644 --- a/src/agents/models-config.providers.matrix.test.ts +++ b/src/agents/models-config.providers.matrix.test.ts @@ -39,7 +39,7 @@ async function writeAuthProfiles( const MATRIX_CASES: MatrixCase[] = [ { name: "env api key injects a simple provider", - env: { NVIDIA_API_KEY: "test-nvidia-key" }, + env: { NVIDIA_API_KEY: "test-nvidia-key" }, // pragma: allowlist secret assertProviders(providers) { expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY"); expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1"); @@ -48,7 +48,7 @@ const MATRIX_CASES: MatrixCase[] = [ }, { name: "env api key injects paired plan providers", - env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, + env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" }, // pragma: allowlist secret assertProviders(providers) { expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); @@ -116,7 +116,7 @@ const MATRIX_CASES: MatrixCase[] = [ }, { name: "explicit vllm config suppresses implicit vllm injection", - env: { VLLM_API_KEY: "test-vllm-key" }, + env: { VLLM_API_KEY: "test-vllm-key" }, // pragma: allowlist secret explicitProviders: { vllm: { baseUrl: "http://127.0.0.1:8000/v1", From 64dd23eadeb0a78d86dcb9b935f5abed2794800d Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 8 Mar 2026 22:44:05 +0200 Subject: [PATCH 163/311] fix(ci): refresh detect-secrets baseline --- .secrets.baseline | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 72adb0685b0ca..2f794ecc01bd2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -252,7 +252,7 @@ "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift", "hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4", "is_verified": false, - "line_number": 66 + "line_number": 81 } ], "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [ @@ -9796,63 +9796,63 @@ "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 1612 + "line_number": 1614 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", "is_verified": false, - "line_number": 1628 + "line_number": 1630 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", "is_verified": false, - "line_number": 1815 + "line_number": 1817 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 1988 + "line_number": 1990 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 2044 + "line_number": 2046 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 2276 + "line_number": 2278 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2404 + "line_number": 2408 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 2657 + "line_number": 2661 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 2659 + "line_number": 2663 } ], "docs/gateway/configuration.md": [ @@ -11482,7 +11482,7 @@ "filename": "src/agents/models-config.e2e-harness.ts", "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", "is_verified": false, - "line_number": 131 + "line_number": 157 } ], "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ @@ -11747,7 +11747,7 @@ "filename": "src/auto-reply/status.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 36 + "line_number": 37 } ], "src/browser/bridge-server.auth.test.ts": [ @@ -11765,14 +11765,14 @@ "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46", "is_verified": false, - "line_number": 43 + "line_number": 47 }, { "type": "Basic Auth Credentials", "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 164 + "line_number": 171 } ], "src/browser/cdp.test.ts": [ @@ -11781,7 +11781,7 @@ "filename": "src/browser/cdp.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 243 + "line_number": 318 } ], "src/channels/plugins/plugins-channel.test.ts": [ @@ -12101,21 +12101,21 @@ "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36", "is_verified": false, - "line_number": 13 + "line_number": 17 }, { "type": "Secret Keyword", "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3", "is_verified": false, - "line_number": 19 + "line_number": 23 }, { "type": "Secret Keyword", "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80", "is_verified": false, - "line_number": 27 + "line_number": 31 } ], "src/config/config.irc.test.ts": [ @@ -12336,14 +12336,14 @@ "filename": "src/config/schema.help.ts", "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", "is_verified": false, - "line_number": 651 + "line_number": 653 }, { "type": "Secret Keyword", "filename": "src/config/schema.help.ts", "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", "is_verified": false, - "line_number": 684 + "line_number": 686 } ], "src/config/schema.irc.ts": [ @@ -12382,14 +12382,14 @@ "filename": "src/config/schema.labels.ts", "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", "is_verified": false, - "line_number": 216 + "line_number": 217 }, { "type": "Secret Keyword", "filename": "src/config/schema.labels.ts", "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", "is_verified": false, - "line_number": 325 + "line_number": 326 } ], "src/config/slack-http-config.test.ts": [ @@ -13035,5 +13035,5 @@ } ] }, - "generated_at": "2026-03-08T20:08:19Z" + "generated_at": "2026-03-08T20:41:38Z" } From 09acbe65289d9f1b6ba54bfcebcb3b9987de0fb5 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 8 Mar 2026 16:53:44 -0400 Subject: [PATCH 164/311] fix: harden backup verify path validation --- src/commands/backup-verify.test.ts | 118 +++++++++++++++++++++++++++++ src/commands/backup-verify.ts | 20 +++++ 2 files changed, 138 insertions(+) diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts index 7723a3c46cdac..9288d2fb8c123 100644 --- a/src/commands/backup-verify.test.ts +++ b/src/commands/backup-verify.test.ts @@ -167,6 +167,64 @@ describe("backupVerifyCommand", () => { } }); + it("fails when archive paths contain backslashes", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-backslash-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPath = path.join(tempDir, "payload.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const invalidPath = `${rootName}/payload\\..\\escaped.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: invalidPath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPath, "payload\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPath) { + entry.path = invalidPath; + } + }, + }, + [manifestPath, payloadPath], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /forward slashes/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("ignores payload manifest.json files when locating the backup manifest", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); @@ -271,4 +329,64 @@ describe("backupVerifyCommand", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("fails when the archive contains duplicate payload entries", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-duplicate-payload-")); + const archivePath = path.join(tempDir, "broken.tar.gz"); + const manifestPath = path.join(tempDir, "manifest.json"); + const payloadPathA = path.join(tempDir, "payload-a.txt"); + const payloadPathB = path.join(tempDir, "payload-b.txt"); + try { + const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup"; + const payloadArchivePath = `${rootName}/payload/posix/tmp/.openclaw/payload.txt`; + const manifest = { + schemaVersion: 1, + createdAt: "2026-03-09T00:00:00.000Z", + archiveRoot: rootName, + runtimeVersion: "test", + platform: process.platform, + nodeVersion: process.version, + assets: [ + { + kind: "state", + sourcePath: "/tmp/.openclaw", + archivePath: payloadArchivePath, + }, + ], + }; + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fs.writeFile(payloadPathA, "payload-a\n", "utf8"); + await fs.writeFile(payloadPathB, "payload-b\n", "utf8"); + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + onWriteEntry: (entry) => { + if (entry.path === manifestPath) { + entry.path = `${rootName}/manifest.json`; + return; + } + if (entry.path === payloadPathA || entry.path === payloadPathB) { + entry.path = payloadArchivePath; + } + }, + }, + [manifestPath, payloadPathA, payloadPathB], + ); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /duplicate entry path/i, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts index 109955bdfb195..0199c8de25968 100644 --- a/src/commands/backup-verify.ts +++ b/src/commands/backup-verify.ts @@ -67,6 +67,9 @@ function normalizeArchivePath(entryPath: string, label: string): string { if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) { throw new Error(`${label} must be relative: ${entryPath}`); } + if (trimmed.includes("\\")) { + throw new Error(`${label} must use forward slashes: ${entryPath}`); + } if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) { throw new Error(`${label} contains path traversal segments: ${entryPath}`); } @@ -260,6 +263,19 @@ function formatResult(result: BackupVerifyResult): string { ].join("\n"); } +function findDuplicateNormalizedEntryPath( + entries: Array<{ normalized: string }>, +): string | undefined { + const seen = new Set(); + for (const entry of entries) { + if (seen.has(entry.normalized)) { + return entry.normalized; + } + seen.add(entry.normalized); + } + return undefined; +} + export async function backupVerifyCommand( runtime: RuntimeEnv, opts: BackupVerifyOptions, @@ -280,6 +296,10 @@ export async function backupVerifyCommand( if (manifestMatches.length !== 1) { throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`); } + const duplicateEntryPath = findDuplicateNormalizedEntryPath(entries); + if (duplicateEntryPath) { + throw new Error(`Archive contains duplicate entry path: ${duplicateEntryPath}`); + } const manifestEntryPath = manifestMatches[0]?.raw; if (!manifestEntryPath) { throw new Error("Backup archive manifest entry could not be resolved."); From 5889a2e98e081987b894e5849a73ca3d324f62f1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 8 Mar 2026 17:13:46 -0400 Subject: [PATCH 165/311] fix(plugin-sdk): lazily load legacy root alias --- src/plugin-sdk/root-alias.cjs | 90 +++++++++++++++++++++++++---- src/plugin-sdk/root-alias.test.ts | 94 +++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 11 deletions(-) diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 9e3c2e5d1a874..12d98caf8a8d1 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -108,26 +108,94 @@ const fastExports = { resolveControlCommandGate, }; -const monolithic = tryLoadMonolithicSdk(); -const rootExports = - monolithic && typeof monolithic === "object" - ? { - ...monolithic, - ...fastExports, +const target = { ...fastExports }; +let rootExports = null; + +function getMonolithicSdk() { + const loaded = tryLoadMonolithicSdk(); + if (loaded && typeof loaded === "object") { + return loaded; + } + return null; +} + +function getExportValue(prop) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop); + } + const monolithic = getMonolithicSdk(); + if (!monolithic) { + return undefined; + } + return Reflect.get(monolithic, prop); +} + +function getExportDescriptor(prop) { + const ownDescriptor = Reflect.getOwnPropertyDescriptor(target, prop); + if (ownDescriptor) { + return ownDescriptor; + } + + const monolithic = getMonolithicSdk(); + if (!monolithic) { + return undefined; + } + + const descriptor = Reflect.getOwnPropertyDescriptor(monolithic, prop); + if (!descriptor) { + return undefined; + } + + // Proxy invariants require descriptors returned for dynamic properties to be configurable. + return { + ...descriptor, + configurable: true, + }; +} + +rootExports = new Proxy(target, { + get(_target, prop, receiver) { + if (Reflect.has(target, prop)) { + return Reflect.get(target, prop, receiver); + } + return getExportValue(prop); + }, + has(_target, prop) { + if (Reflect.has(target, prop)) { + return true; + } + const monolithic = getMonolithicSdk(); + return monolithic ? Reflect.has(monolithic, prop) : false; + }, + ownKeys() { + const keys = new Set(Reflect.ownKeys(target)); + const monolithic = getMonolithicSdk(); + if (monolithic) { + for (const key of Reflect.ownKeys(monolithic)) { + if (!keys.has(key)) { + keys.add(key); + } } - : { ...fastExports }; + } + return [...keys]; + }, + getOwnPropertyDescriptor(_target, prop) { + return getExportDescriptor(prop); + }, +}); -Object.defineProperty(rootExports, "__esModule", { +Object.defineProperty(target, "__esModule", { configurable: true, enumerable: false, writable: false, value: true, }); -Object.defineProperty(rootExports, "default", { +Object.defineProperty(target, "default", { configurable: true, enumerable: false, - writable: false, - value: rootExports, + get() { + return rootExports; + }, }); module.exports = rootExports; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 8757d3ce34cfb..4822c2473231c 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -1,8 +1,14 @@ +import fs from "node:fs"; import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); const rootSdk = require("./root-alias.cjs") as Record; +const rootAliasPath = fileURLToPath(new URL("./root-alias.cjs", import.meta.url)); +const rootAliasSource = fs.readFileSync(rootAliasPath, "utf-8"); type EmptySchema = { safeParse: (value: unknown) => @@ -13,6 +19,64 @@ type EmptySchema = { }; }; +function loadRootAliasWithStubs(options?: { + distExists?: boolean; + monolithicExports?: Record; +}) { + let createJitiCalls = 0; + let jitiLoadCalls = 0; + const loadedSpecifiers: string[] = []; + const monolithicExports = options?.monolithicExports ?? { + slowHelper: () => "loaded", + }; + const wrapper = vm.runInNewContext( + `(function (exports, require, module, __filename, __dirname) {${rootAliasSource}\n})`, + {}, + { filename: rootAliasPath }, + ) as ( + exports: Record, + require: NodeJS.Require, + module: { exports: Record }, + __filename: string, + __dirname: string, + ) => void; + const module = { exports: {} as Record }; + const localRequire = ((id: string) => { + if (id === "node:path") { + return path; + } + if (id === "node:fs") { + return { + existsSync: () => options?.distExists ?? false, + }; + } + if (id === "jiti") { + return { + createJiti() { + createJitiCalls += 1; + return (specifier: string) => { + jitiLoadCalls += 1; + loadedSpecifiers.push(specifier); + return monolithicExports; + }; + }, + }; + } + throw new Error(`unexpected require: ${id}`); + }) as NodeJS.Require; + wrapper(module.exports, localRequire, module, rootAliasPath, path.dirname(rootAliasPath)); + return { + moduleExports: module.exports, + get createJitiCalls() { + return createJitiCalls; + }, + get jitiLoadCalls() { + return jitiLoadCalls; + }, + loadedSpecifiers, + }; +} + describe("plugin-sdk root alias", () => { it("exposes the fast empty config schema helper", () => { const factory = rootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined; @@ -27,6 +91,36 @@ describe("plugin-sdk root alias", () => { expect(parsed.success).toBe(false); }); + it("does not load the monolithic sdk for fast helpers", () => { + const lazyModule = loadRootAliasWithStubs(); + const lazyRootSdk = lazyModule.moduleExports; + const factory = lazyRootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined; + + expect(lazyModule.createJitiCalls).toBe(0); + expect(lazyModule.jitiLoadCalls).toBe(0); + expect(typeof factory).toBe("function"); + expect(factory?.().safeParse({})).toEqual({ success: true, data: {} }); + expect(lazyModule.createJitiCalls).toBe(0); + expect(lazyModule.jitiLoadCalls).toBe(0); + }); + + it("loads legacy root exports on demand and preserves reflection", () => { + const lazyModule = loadRootAliasWithStubs({ + monolithicExports: { + slowHelper: () => "loaded", + }, + }); + const lazyRootSdk = lazyModule.moduleExports; + + expect(lazyModule.createJitiCalls).toBe(0); + expect("slowHelper" in lazyRootSdk).toBe(true); + expect(lazyModule.createJitiCalls).toBe(1); + expect(lazyModule.jitiLoadCalls).toBe(1); + expect((lazyRootSdk.slowHelper as () => string)()).toBe("loaded"); + expect(Object.keys(lazyRootSdk)).toContain("slowHelper"); + expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); + }); + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); expect(typeof rootSdk.default).toBe("object"); From 7dfd77abebeb2dd04e3367fe334d4d860ac82e09 Mon Sep 17 00:00:00 2001 From: langdon Date: Sun, 8 Mar 2026 17:32:08 -0400 Subject: [PATCH 166/311] fix(setup-podman): cd to TMPDIR before podman load to avoid cwd permission error (#39435) * fix(setup-podman): cd to TMPDIR before podman load to avoid inherited cwd permission error * fix(podman): safe cwd in run_as_user to prevent chdir errors Co-Authored-By: Claude Opus 4.6 Signed-off-by: sallyom --------- Signed-off-by: sallyom Co-authored-by: sallyom Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + setup-podman.sh | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f5ca2810fb7..74ebdce9f0f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. - macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. +- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. ## 2026.3.7 diff --git a/setup-podman.sh b/setup-podman.sh index 95a4415487c7b..5b904684ffa67 100755 --- a/setup-podman.sh +++ b/setup-podman.sh @@ -80,12 +80,17 @@ run_root() { } run_as_user() { + # When switching users, the caller's cwd may be inaccessible to the target + # user (e.g. a private home dir). Wrap in a subshell that cd's to a + # world-traversable directory so sudo/runuser don't fail with "cannot chdir". + # TODO: replace with fully rootless podman build to eliminate the need for + # user-switching entirely. local user="$1" shift if command -v sudo >/dev/null 2>&1; then - sudo -u "$user" "$@" + ( cd /tmp 2>/dev/null || cd /; sudo -u "$user" "$@" ) elif is_root && command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" + ( cd /tmp 2>/dev/null || cd /; runuser -u "$user" -- "$@" ) else echo "Need sudo (or root+runuser) to run commands as $user." >&2 exit 1 From 38543d819690155b350fa358cf68b98d11a0fe6d Mon Sep 17 00:00:00 2001 From: Tyler Yust <64381258+tyler6204@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:46:33 -0700 Subject: [PATCH 167/311] fix(cron): consolidate announce delivery, fire-and-forget trigger, and minimal prompt mode (#40204) * fix(cron): consolidate announce delivery and detach manual runs * fix: queue detached cron runs (#40204) --- CHANGELOG.md | 1 + docs/automation/cron-jobs.md | 2 + docs/cli/cron.md | 2 + .../pi-embedded-runner/run/attempt.test.ts | 12 +- src/agents/pi-embedded-runner/run/attempt.ts | 4 +- src/agents/subagent-announce.timeout.test.ts | 19 ++ src/agents/subagent-announce.ts | 14 +- src/cli/cron-cli.test.ts | 22 +- src/cli/cron-cli/register.cron-simple.ts | 4 +- .../delivery-dispatch.double-announce.test.ts | 23 ++ src/cron/service.issue-regressions.test.ts | 114 ++++++- src/cron/service.ts | 4 + src/cron/service/ops.ts | 114 ++++++- src/cron/service/state.ts | 1 + src/gateway/server-methods/cron.ts | 2 +- src/gateway/server.cron.test.ts | 284 ++++++++++++++---- 16 files changed, 534 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ebdce9f0f22..d20ca2541a374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. - Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent `RangeError: Invalid string length` on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888. - iMessage/cron completion announces: strip leaked inline reply tags (for example `[[reply_to:6100]]`) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc. +- Cron/manual run enqueue flow: queue `cron.run` requests behind the cron execution lane, return immediate `{ ok: true, enqueued: true, runId }` acknowledgements, preserve `{ ok: true, ran: false, reason }` skip responses for already-running and not-due jobs, and document the asynchronous completion flow. (#40204) - Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt. - Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker. - Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b0798898910a7..47bae78b86fb7 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -620,6 +620,8 @@ openclaw cron run openclaw cron run --due ``` +`cron.run` now acknowledges once the manual run is queued, not after the job finishes. Successful queue responses look like `{ ok: true, enqueued: true, runId }`. If the job is already running or `--due` finds nothing due, the response stays `{ ok: true, ran: false, reason }`. Use `openclaw cron runs --id ` or the `cron.runs` gateway method to inspect the eventual finished entry. + Edit an existing job (patch fields): ```bash diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 5f5be713de14b..28e61e20c993b 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -23,6 +23,8 @@ Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after- Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run. +Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id ` to follow the eventual outcome. + Note: retention/pruning is controlled in config: - `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 76c4253aa4b53..70bd3242f7c9c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -135,9 +135,15 @@ describe("resolvePromptModeForSession", () => { 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"); + it("uses minimal mode for cron sessions", () => { + expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("minimal"); + expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("minimal"); + }); + + it("uses full mode for regular and undefined sessions", () => { + expect(resolvePromptModeForSession(undefined)).toBe("full"); + expect(resolvePromptModeForSession("agent:main")).toBe("full"); + expect(resolvePromptModeForSession("agent:main:thread:abc")).toBe("full"); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b57159e52aa02..e480eb77797ba 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 { isSubagentSessionKey } from "../../../routing/session-key.js"; +import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; @@ -613,7 +613,7 @@ export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "f if (!sessionKey) { return "full"; } - return isSubagentSessionKey(sessionKey) ? "minimal" : "full"; + return isSubagentSessionKey(sessionKey) || isCronSessionKey(sessionKey) ? "minimal" : "full"; } export function resolveAttemptFsWorkspaceOnly(params: { diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index 346989f493e02..1c4925d9272e7 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -197,6 +197,25 @@ describe("subagent announce timeout config", () => { expect(internalEvents[0]?.announceType).toBe("cron job"); }); + it("regression, keeps child announce internal when requester is a cron run session", async () => { + const cronSessionKey = "agent:main:cron:daily-check:run:run-123"; + + await runAnnounceFlowForTest("run-cron-internal", { + requesterSessionKey: cronSessionKey, + requesterDisplayKey: cronSessionKey, + requesterOrigin: { channel: "discord", to: "channel:cron-results", accountId: "acct-1" }, + }); + + const directAgentCall = findGatewayCall( + (call) => call.method === "agent" && call.expectFinal === true, + ); + expect(directAgentCall?.params?.sessionKey).toBe(cronSessionKey); + expect(directAgentCall?.params?.deliver).toBe(false); + expect(directAgentCall?.params?.channel).toBeUndefined(); + expect(directAgentCall?.params?.to).toBeUndefined(); + expect(directAgentCall?.params?.accountId).toBeUndefined(); + }); + it("regression, routes child announce to parent session instead of grandparent when parent session still exists", async () => { const parentSessionKey = "agent:main:subagent:parent"; requesterDepthResolver = (sessionKey?: string) => diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 83391755e9c4b..62b2cc6f0d3d4 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -14,6 +14,7 @@ import type { ConversationRef } from "../infra/outbound/session-binding-service. import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; +import { isCronSessionKey } from "../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import { type DeliveryContext, @@ -78,6 +79,10 @@ function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): n return Math.min(Math.max(1, Math.floor(configured)), MAX_TIMER_SAFE_TIMEOUT_MS); } +function isInternalAnnounceRequesterSession(sessionKey: string | undefined): boolean { + return getSubagentDepthFromSessionStore(sessionKey) >= 1 || isCronSessionKey(sessionKey); +} + function summarizeDeliveryError(error: unknown): string { if (error instanceof Error) { return error.message || "error"; @@ -580,8 +585,7 @@ async function resolveSubagentCompletionOrigin(params: { async function sendAnnounce(item: AnnounceQueueItem) { const cfg = loadConfig(); const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); - const requesterDepth = getSubagentDepthFromSessionStore(item.sessionKey); - const requesterIsSubagent = requesterDepth >= 1; + const requesterIsSubagent = isInternalAnnounceRequesterSession(item.sessionKey); const origin = item.origin; const threadId = origin?.threadId != null && origin.threadId !== "" ? String(origin.threadId) : undefined; @@ -1216,6 +1220,8 @@ export async function runSubagentAnnounceFlow(params: { } let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); + const requesterIsInternalSession = () => + requesterDepth >= 1 || isCronSessionKey(targetRequesterSessionKey); let childCompletionFindings: string | undefined; let subagentRegistryRuntime: @@ -1339,7 +1345,7 @@ export async function runSubagentAnnounceFlow(params: { const announceSessionId = childSessionId || "unknown"; const findings = childCompletionFindings || reply || "(no output)"; - let requesterIsSubagent = requesterDepth >= 1; + let requesterIsSubagent = requesterIsInternalSession(); if (requesterIsSubagent) { const { isSubagentSessionRunActive, @@ -1363,7 +1369,7 @@ export async function runSubagentAnnounceFlow(params: { targetRequesterOrigin = normalizeDeliveryContext(fallback.requesterOrigin) ?? targetRequesterOrigin; requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); - requesterIsSubagent = requesterDepth >= 1; + requesterIsSubagent = requesterIsInternalSession(); } } } diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 562a239385d76..a6b20ca5b3d23 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -156,7 +156,11 @@ async function expectCronEditWithScheduleLookupExit( ).rejects.toThrow("__exit__:1"); } -async function runCronRunAndCaptureExit(params: { ran: boolean; args?: string[] }) { +async function runCronRunAndCaptureExit(params: { + ran?: boolean; + enqueued?: boolean; + args?: string[]; +}) { resetGatewayMock(); callGatewayFromCli.mockImplementation( async (method: string, _opts: unknown, callParams?: unknown) => { @@ -164,7 +168,12 @@ async function runCronRunAndCaptureExit(params: { ran: boolean; args?: string[] return { enabled: true }; } if (method === "cron.run") { - return { ok: true, params: callParams, ran: params.ran }; + return { + ok: true, + params: callParams, + ...(typeof params.ran === "boolean" ? { ran: params.ran } : {}), + ...(typeof params.enqueued === "boolean" ? { enqueued: params.enqueued } : {}), + }; } return { ok: true, params: callParams }; }, @@ -195,13 +204,18 @@ describe("cron cli", () => { ran: true, expectedExitCode: 0, }, + { + name: "exits 0 for cron run when job is queued successfully", + enqueued: true, + expectedExitCode: 0, + }, { name: "exits 1 for cron run when job does not execute", ran: false, expectedExitCode: 1, }, - ])("$name", async ({ ran, expectedExitCode }) => { - const { exitSpy } = await runCronRunAndCaptureExit({ ran }); + ])("$name", async ({ ran, enqueued, expectedExitCode }) => { + const { exitSpy } = await runCronRunAndCaptureExit({ ran, enqueued }); expect(exitSpy).toHaveBeenCalledWith(expectedExitCode); }); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index ae05ff1fa69a1..891d86919688f 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -99,8 +99,8 @@ export function registerCronSimpleCommands(cron: Command) { mode: opts.due ? "due" : "force", }); printCronJson(res); - const result = res as { ok?: boolean; ran?: boolean } | undefined; - defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1); + const result = res as { ok?: boolean; ran?: boolean; enqueued?: boolean } | undefined; + defaultRuntime.exit(result?.ok && (result?.ran || result?.enqueued) ? 0 : 1); } catch (err) { handleCronCliError(err); } diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index fdb77fc22babc..abaf1ae5349bf 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -208,6 +208,29 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); }); + it("consolidates descendant output into the cron announce path", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(true); + vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue( + "Detailed child result, everything finished successfully.", + ); + vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); + + const params = makeBaseParams({ synthesizedText: "on it" }); + const state = await dispatchCronDelivery(params); + + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + expect(runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect.objectContaining({ + roundOneReply: "Detailed child result, everything finished successfully.", + expectsCompletionMessage: true, + announceType: "cron job", + }), + ); + }); + it("normal announce success delivers exactly once and sets deliveryAttempted=true", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index b2276aeb39890..dac28f4b0c970 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js"; +import { clearCommandLane, setCommandLaneConcurrency } from "../process/command-queue.js"; +import { CommandLane } from "../process/lanes.js"; import * as schedule from "./schedule.js"; import { createAbortAwareIsolatedRunner, @@ -15,9 +17,13 @@ import { writeCronStoreSnapshot, } from "./service.issue-regressions.test-helpers.js"; import { CronService } from "./service.js"; -import { createDeferred, createRunningCronServiceState } from "./service.test-harness.js"; +import { + createDeferred, + createNoopLogger, + createRunningCronServiceState, +} from "./service.test-harness.js"; import { computeJobNextRunAtMs } from "./service/jobs.js"; -import { run } from "./service/ops.js"; +import { enqueueRun, run } from "./service/ops.js"; import { createCronServiceState, type CronEvent } from "./service/state.js"; import { DEFAULT_JOB_TIMEOUT_MS, @@ -1486,6 +1492,110 @@ describe("Cron issue regressions", () => { expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok"); }); + it("queues manual cron.run requests behind the cron execution lane", async () => { + vi.useRealTimers(); + clearCommandLane(CommandLane.Cron); + setCommandLaneConcurrency(CommandLane.Cron, 1); + + const store = makeStorePath(); + const dueAt = Date.parse("2026-02-06T10:05:02.000Z"); + const first = createDueIsolatedJob({ id: "queued-first", nowMs: dueAt, nextRunAtMs: dueAt }); + const second = createDueIsolatedJob({ + id: "queued-second", + nowMs: dueAt, + nextRunAtMs: dueAt, + }); + await fs.writeFile( + store.storePath, + JSON.stringify({ version: 1, jobs: [first, second] }), + "utf-8", + ); + + let now = dueAt; + let activeRuns = 0; + let peakActiveRuns = 0; + const firstRun = createDeferred<{ status: "ok"; summary: string }>(); + const secondRun = createDeferred<{ status: "ok"; summary: string }>(); + const secondStarted = createDeferred(); + const runIsolatedAgentJob = vi.fn(async (params: { job: { id: string } }) => { + activeRuns += 1; + peakActiveRuns = Math.max(peakActiveRuns, activeRuns); + if (params.job.id === second.id) { + secondStarted.resolve(); + } + try { + const result = + params.job.id === first.id ? await firstRun.promise : await secondRun.promise; + now += 10; + return result; + } finally { + activeRuns -= 1; + } + }); + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + cronConfig: { maxConcurrentRuns: 1 }, + log: createNoopLogger(), + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob, + }); + + const firstAck = await enqueueRun(state, first.id, "force"); + const secondAck = await enqueueRun(state, second.id, "force"); + expect(firstAck).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + expect(secondAck).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + + await vi.waitFor(() => expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1)); + expect(runIsolatedAgentJob.mock.calls[0]?.[0]).toMatchObject({ job: { id: first.id } }); + expect(peakActiveRuns).toBe(1); + + firstRun.resolve({ status: "ok", summary: "first queued run" }); + await secondStarted.promise; + expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2); + expect(runIsolatedAgentJob.mock.calls[1]?.[0]).toMatchObject({ job: { id: second.id } }); + expect(peakActiveRuns).toBe(1); + + secondRun.resolve({ status: "ok", summary: "second queued run" }); + await vi.waitFor(() => { + const jobs = state.store?.jobs ?? []; + expect(jobs.find((job) => job.id === first.id)?.state.lastStatus).toBe("ok"); + expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok"); + }); + + clearCommandLane(CommandLane.Cron); + }); + + it("logs unexpected queued manual run background failures once", async () => { + vi.useRealTimers(); + clearCommandLane(CommandLane.Cron); + setCommandLaneConcurrency(CommandLane.Cron, 1); + + const dueAt = Date.parse("2026-02-06T10:05:03.000Z"); + const job = createDueIsolatedJob({ id: "queued-failure", nowMs: dueAt, nextRunAtMs: dueAt }); + const log = createNoopLogger(); + const badStore = `${makeStorePath().storePath}.dir`; + await fs.mkdir(badStore, { recursive: true }); + const state = createRunningCronServiceState({ + storePath: badStore, + log, + nowMs: () => dueAt, + jobs: [job], + }); + + const result = await enqueueRun(state, job.id, "force"); + expect(result).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + + await vi.waitFor(() => expect(log.error).toHaveBeenCalledTimes(1)); + expect(log.error.mock.calls[0]?.[1]).toBe( + "cron: queued manual run background execution failed", + ); + + clearCommandLane(CommandLane.Cron); + }); + // Regression: isolated cron runs must not abort at 1/3 of configured timeoutSeconds. // The bug (issue #29774) caused the CLI-provider resume watchdog (ratio 0.3, maxMs 180 s) // to be applied on fresh sessions because a persisted cliSessionId was passed to diff --git a/src/cron/service.ts b/src/cron/service.ts index 7ccc1cc59e01c..a221cb68b158f 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -46,6 +46,10 @@ export class CronService { return await ops.run(this.state, id, mode); } + async enqueueRun(id: string, mode?: "due" | "force") { + return await ops.enqueueRun(this.state, id, mode); + } + getJob(id: string): CronJob | undefined { return this.state.store?.jobs.find((job) => job.id === id); } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 9f575134c230a..c027c8d553f8b 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -1,3 +1,5 @@ +import { enqueueCommandInLane } from "../../process/command-queue.js"; +import { CommandLane } from "../../process/lanes.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js"; import { normalizeCronCreateDeliveryInput } from "./initial-delivery.js"; import { @@ -339,8 +341,58 @@ export async function remove(state: CronServiceState, id: string) { }); } -export async function run(state: CronServiceState, id: string, mode?: "due" | "force") { - const prepared = await locked(state, async () => { +type PreparedManualRun = + | { + ok: true; + ran: false; + reason: "already-running" | "not-due"; + } + | { + ok: true; + ran: true; + jobId: string; + startedAt: number; + executionJob: CronJob; + } + | { ok: false }; + +type ManualRunDisposition = + | Extract + | { ok: true; runnable: true }; + +let nextManualRunId = 1; + +async function inspectManualRunDisposition( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + return await locked(state, async () => { + warnIfDisabled(state, "run"); + await ensureLoaded(state, { skipRecompute: true }); + // Normalize job tick state (clears stale runningAtMs markers) before + // checking if already running, so a stale marker from a crashed Phase-1 + // persist does not block manual triggers for up to STUCK_RUN_MS (#17554). + recomputeNextRunsForMaintenance(state); + const job = findJobOrThrow(state, id); + if (typeof job.state.runningAtMs === "number") { + return { ok: true, ran: false, reason: "already-running" as const }; + } + const now = state.deps.nowMs(); + const due = isJobDue(job, now, { forced: mode === "force" }); + if (!due) { + return { ok: true, ran: false, reason: "not-due" as const }; + } + return { ok: true, runnable: true } as const; + }); +} + +async function prepareManualRun( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + return await locked(state, async () => { warnIfDisabled(state, "run"); await ensureLoaded(state, { skipRecompute: true }); // Normalize job tick state (clears stale runningAtMs markers) before @@ -365,7 +417,7 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f // force-reload from disk cannot start the same job concurrently. await persist(state); emit(state, { jobId: job.id, action: "started", runAtMs: now }); - const executionJob = JSON.parse(JSON.stringify(job)) as typeof job; + const executionJob = JSON.parse(JSON.stringify(job)) as CronJob; return { ok: true, ran: true, @@ -374,13 +426,13 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f executionJob, } as const; }); +} - if (!prepared.ran) { - return prepared; - } - if (!prepared.executionJob || typeof prepared.startedAt !== "number") { - return { ok: false } as const; - } +async function finishPreparedManualRun( + state: CronServiceState, + prepared: Extract, + mode?: "due" | "force", +): Promise { const executionJob = prepared.executionJob; const startedAt = prepared.startedAt; const jobId = prepared.jobId; @@ -461,10 +513,54 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f await persist(state); armTimer(state); }); +} +export async function run(state: CronServiceState, id: string, mode?: "due" | "force") { + const prepared = await prepareManualRun(state, id, mode); + if (!prepared.ok || !prepared.ran) { + return prepared; + } + await finishPreparedManualRun(state, prepared, mode); return { ok: true, ran: true } as const; } +export async function enqueueRun(state: CronServiceState, id: string, mode?: "due" | "force") { + const disposition = await inspectManualRunDisposition(state, id, mode); + if (!disposition.ok || !("runnable" in disposition && disposition.runnable)) { + return disposition; + } + + const runId = `manual:${id}:${state.deps.nowMs()}:${nextManualRunId++}`; + void enqueueCommandInLane( + CommandLane.Cron, + async () => { + const result = await run(state, id, mode); + if (result.ok && "ran" in result && !result.ran) { + state.deps.log.info( + { jobId: id, runId, reason: result.reason }, + "cron: queued manual run skipped before execution", + ); + } + return result; + }, + { + warnAfterMs: 5_000, + onWait: (waitMs, queuedAhead) => { + state.deps.log.warn( + { jobId: id, runId, waitMs, queuedAhead }, + "cron: queued manual run waiting for an execution slot", + ); + }, + }, + ).catch((err) => { + state.deps.log.error( + { jobId: id, runId, err: String(err) }, + "cron: queued manual run background execution failed", + ); + }); + return { ok: true, enqueued: true, runId } as const; +} + export function wakeNow( state: CronServiceState, opts: { mode: "now" | "next-heartbeat"; text: string }, diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index b65d0ebaa1431..1e42ae089cd7e 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -142,6 +142,7 @@ export type CronStatusSummary = { export type CronRunResult = | { ok: true; ran: true } + | { ok: true; enqueued: true; runId: string } | { ok: true; ran: false; reason: "not-due" } | { ok: true; ran: false; reason: "already-running" } | { ok: false }; diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index a6549c503f6d7..830d12c9509f3 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -212,7 +212,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const result = await context.cron.run(jobId, p.mode ?? "force"); + const result = await context.cron.enqueueRun(jobId, p.mode ?? "force"); respond(true, result, undefined); }, "cron.runs": async ({ params, respond, context }) => { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 4a21354605d68..ccaf54412372c 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -9,6 +9,7 @@ import { connectOk, cronIsolatedRun, installGatewayTestHooks, + onceMessage, rpcReq, startServerWithClient, testState, @@ -35,7 +36,6 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ })); installGatewayTestHooks({ scope: "suite" }); -const CRON_WAIT_INTERVAL_MS = 5; const CRON_WAIT_TIMEOUT_MS = 3_000; const EMPTY_CRON_STORE_CONTENT = JSON.stringify({ version: 1, jobs: [] }); let cronSuiteTempRootPromise: Promise | null = null; @@ -69,16 +69,20 @@ async function rmTempDir(dir: string) { await fs.rm(dir, { recursive: true, force: true }); } -async function waitForCondition(check: () => boolean | Promise, timeoutMs = 2000) { - await vi.waitFor( - async () => { - const ok = await check(); - if (!ok) { - throw new Error("condition not met"); - } +async function waitForCronEvent( + ws: WebSocket, + check: (payload: Record | null) => boolean, + timeoutMs = CRON_WAIT_TIMEOUT_MS, +) { + const message = await onceMessage( + ws, + (obj) => { + const payload = obj.payload ?? null; + return obj.type === "event" && obj.event === "cron" && check(payload); }, - { timeout: timeoutMs, interval: CRON_WAIT_INTERVAL_MS }, + timeoutMs, ); + return message.payload ?? null; } async function createCronCasePaths(tempPrefix: string): Promise<{ @@ -178,6 +182,8 @@ async function addWebhookCronJob(params: { async function runCronJobForce(ws: WebSocket, id: string) { const response = await rpcReq(ws, "cron.run", { id, mode: "force" }, 20_000); expect(response.ok).toBe(true); + expect(response.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + return response; } function getWebhookCall(index: number) { @@ -263,6 +269,7 @@ describe("gateway server cron", () => { const runRes = await rpcReq(ws, "cron.run", { id: routeJobId, mode: "force" }, 20_000); expect(runRes.ok).toBe(true); + expect(runRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); const events = await waitForSystemEvent(); expect(events.some((event) => event.includes("cron route check"))).toBe(true); @@ -441,7 +448,7 @@ describe("gateway server cron", () => { }); test("writes cron run history and auto-runs due jobs", async () => { - const { prevSkipCron, dir } = await setupCronTestRun({ + const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-log-", }); @@ -463,31 +470,21 @@ describe("gateway server cron", () => { const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); + const finishedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "finished", + ); const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000); expect(runRes.ok).toBe(true); - const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`); - let raw = ""; - await waitForCondition(async () => { - raw = await fs.readFile(logPath, "utf-8").catch(() => ""); - return raw.trim().length > 0; - }, CRON_WAIT_TIMEOUT_MS); - const line = raw - .split("\n") - .map((l) => l.trim()) - .filter(Boolean) - .at(-1); - const last = JSON.parse(line ?? "{}") as { - jobId?: unknown; - action?: unknown; - status?: unknown; - summary?: unknown; - deliveryStatus?: unknown; - }; - expect(last.action).toBe("finished"); - expect(last.jobId).toBe(jobId); - expect(last.status).toBe("ok"); - expect(last.summary).toBe("hello"); - expect(last.deliveryStatus).toBe("not-requested"); + expect(runRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + const finishedPayload = await finishedRun; + expect(finishedPayload).toMatchObject({ + jobId, + action: "finished", + status: "ok", + summary: "hello", + deliveryStatus: "not-requested", + }); const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 }); expect(runsRes.ok).toBe(true); @@ -522,7 +519,7 @@ describe("gateway server cron", () => { const autoRes = await rpcReq(ws, "cron.add", { name: "auto run test", enabled: true, - schedule: { kind: "at", at: new Date(Date.now() + 50).toISOString() }, + schedule: { kind: "at", at: new Date(Date.now() + 200).toISOString() }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "auto" }, @@ -532,11 +529,10 @@ describe("gateway server cron", () => { const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : ""; expect(autoJobId.length > 0).toBe(true); - await waitForCondition(async () => { - const runsRes = await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 }); - const runsPayload = runsRes.payload as { entries?: unknown } | undefined; - return Array.isArray(runsPayload?.entries) && runsPayload.entries.length > 0; - }, CRON_WAIT_TIMEOUT_MS); + await waitForCronEvent( + ws, + (payload) => payload?.jobId === autoJobId && payload?.action === "finished", + ); const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as | { entries?: Array<{ jobId?: unknown }> } | undefined; @@ -548,6 +544,162 @@ describe("gateway server cron", () => { } }, 45_000); + test("returns from cron.run immediately while isolated work continues in background", async () => { + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-run-detached-", + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + let resolveRun: ((value: { status: "ok"; summary: string }) => void) | undefined; + cronIsolatedRun.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRun = resolve as (value: { status: "ok"; summary: string }) => void; + }), + ); + + try { + const addRes = await rpcReq(ws, "cron.add", { + name: "detached run test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "do work" }, + delivery: { mode: "none" }, + }); + expect(addRes.ok).toBe(true); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; + expect(jobId.length > 0).toBe(true); + + const startedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "started", + ); + const finishedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === jobId && payload?.action === "finished", + ); + const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 1_000); + expect(runRes.ok).toBe(true); + expect(runRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await startedRun; + expect(cronIsolatedRun).toHaveBeenCalledTimes(1); + + resolveRun?.({ status: "ok", summary: "background finished" }); + const finishedPayload = await finishedRun; + expect(finishedPayload).toMatchObject({ + jobId, + action: "finished", + status: "ok", + summary: "background finished", + }); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + + test("returns already-running without starting background work", async () => { + const now = Date.now(); + let resolveRun: ((result: { status: "ok"; summary: string }) => void) | undefined; + cronIsolatedRun.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRun = resolve; + }), + ); + + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-run-busy-", + jobs: [ + { + id: "busy-job", + name: "busy job", + enabled: true, + createdAtMs: now - 60_000, + updatedAtMs: now - 60_000, + schedule: { kind: "at", at: new Date(now + 60_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "still busy" }, + delivery: { mode: "none" }, + state: { + nextRunAtMs: now + 60_000, + }, + }, + ], + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + try { + const startedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === "busy-job" && payload?.action === "started", + ); + const firstRunRes = await rpcReq(ws, "cron.run", { id: "busy-job", mode: "force" }, 1_000); + expect(firstRunRes.ok).toBe(true); + expect(firstRunRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await startedRun; + expect(cronIsolatedRun).toHaveBeenCalledTimes(1); + + const secondRunRes = await rpcReq(ws, "cron.run", { id: "busy-job", mode: "force" }, 1_000); + expect(secondRunRes.ok).toBe(true); + expect(secondRunRes.payload).toEqual({ ok: true, ran: false, reason: "already-running" }); + expect(cronIsolatedRun).toHaveBeenCalledTimes(1); + + const finishedRun = waitForCronEvent( + ws, + (payload) => payload?.jobId === "busy-job" && payload?.action === "finished", + ); + resolveRun?.({ status: "ok", summary: "busy done" }); + await finishedRun; + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + + test("returns not-due without starting background work", async () => { + const now = Date.now(); + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-run-not-due-", + jobs: [ + { + id: "future-job", + name: "future job", + enabled: true, + createdAtMs: now - 60_000, + updatedAtMs: now - 60_000, + schedule: { kind: "at", at: new Date(now + 60_000).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "not yet" }, + delivery: { mode: "none" }, + state: { + nextRunAtMs: now + 60_000, + }, + }, + ], + }); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + cronIsolatedRun.mockClear(); + + try { + const runRes = await rpcReq(ws, "cron.run", { id: "future-job", mode: "due" }, 1_000); + expect(runRes.ok).toBe(true); + expect(runRes.payload).toEqual({ ok: true, ran: false, reason: "not-due" }); + expect(cronIsolatedRun).not.toHaveBeenCalled(); + } finally { + await cleanupCronTestRun({ ws, server, prevSkipCron }); + } + }); + test("posts webhooks for delivery mode and legacy notify fallback only when summary exists", async () => { const legacyNotifyJob = { id: "legacy-notify-job", @@ -608,12 +760,12 @@ describe("gateway server cron", () => { name: "webhook enabled", delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - await runCronJobForce(ws, notifyJobId); - - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 1, - CRON_WAIT_TIMEOUT_MS, + const notifyFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === notifyJobId && payload?.action === "finished", ); + await runCronJobForce(ws, notifyJobId); + await notifyFinished; const notifyCall = getWebhookCall(0); expect(notifyCall.url).toBe("https://example.invalid/cron-finished"); expect(notifyCall.init.method).toBe("POST"); @@ -623,6 +775,10 @@ describe("gateway server cron", () => { expect(notifyBody.action).toBe("finished"); expect(notifyBody.jobId).toBe(notifyJobId); + const legacyFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === "legacy-notify-job" && payload?.action === "finished", + ); const legacyRunRes = await rpcReq( ws, "cron.run", @@ -630,10 +786,8 @@ describe("gateway server cron", () => { 20_000, ); expect(legacyRunRes.ok).toBe(true); - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 2, - CRON_WAIT_TIMEOUT_MS, - ); + expect(legacyRunRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await legacyFinished; const legacyCall = getWebhookCall(1); expect(legacyCall.url).toBe("https://legacy.example.invalid/cron-finished"); expect(legacyCall.init.method).toBe("POST"); @@ -655,10 +809,14 @@ describe("gateway server cron", () => { const silentJobId = typeof silentJobIdValue === "string" ? silentJobIdValue : ""; expect(silentJobId.length > 0).toBe(true); + const silentFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === silentJobId && payload?.action === "finished", + ); const silentRunRes = await rpcReq(ws, "cron.run", { id: silentJobId, mode: "force" }, 20_000); expect(silentRunRes.ok).toBe(true); - await yieldToEventLoop(); - await yieldToEventLoop(); + expect(silentRunRes.payload).toEqual({ ok: true, enqueued: true, runId: expect.any(String) }); + await silentFinished; expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); fetchWithSsrFGuardMock.mockClear(); @@ -677,11 +835,12 @@ describe("gateway server cron", () => { }, }, }); - await runCronJobForce(ws, failureDestJobId); - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 1, - CRON_WAIT_TIMEOUT_MS, + const failureDestFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === failureDestJobId && payload?.action === "finished", ); + await runCronJobForce(ws, failureDestJobId); + await failureDestFinished; const failureDestCall = getWebhookCall(0); expect(failureDestCall.url).toBe("https://example.invalid/failure-destination"); const failureDestBody = failureDestCall.body; @@ -696,9 +855,12 @@ describe("gateway server cron", () => { sessionTarget: "isolated", delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); + const noSummaryFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === noSummaryJobId && payload?.action === "finished", + ); await runCronJobForce(ws, noSummaryJobId); - await yieldToEventLoop(); - await yieldToEventLoop(); + await noSummaryFinished; expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); } finally { await cleanupCronTestRun({ ws, server, prevSkipCron }); @@ -741,12 +903,12 @@ describe("gateway server cron", () => { name: "webhook secretinput object", delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" }, }); - await runCronJobForce(ws, notifyJobId); - - await waitForCondition( - () => fetchWithSsrFGuardMock.mock.calls.length === 1, - CRON_WAIT_TIMEOUT_MS, + const notifyFinished = waitForCronEvent( + ws, + (payload) => payload?.jobId === notifyJobId && payload?.action === "finished", ); + await runCronJobForce(ws, notifyJobId); + await notifyFinished; const [notifyArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [ { url?: string; From e806c479f538f167d1bfbb23327645eb19c095ca Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:46:54 +0100 Subject: [PATCH 168/311] Gateway/iOS: replay queued foreground actions safely after resume (#40281) Merged via squash. - Local validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts` - Local validation: `pnpm build` - mb-server validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts` - mb-server validation: `pnpm build` - mb-server validation: `pnpm protocol:check` --- apps/ios/Sources/Model/NodeAppModel.swift | 111 +++++++++ apps/ios/Tests/NodeAppModelInvokeTests.swift | 35 +++ .../OpenClawProtocol/GatewayModels.swift | 14 ++ .../OpenClawProtocol/GatewayModels.swift | 14 ++ src/gateway/method-scopes.ts | 2 + src/gateway/protocol/index.ts | 6 + src/gateway/protocol/schema/nodes.ts | 7 + .../protocol/schema/protocol-schemas.ts | 2 + src/gateway/protocol/schema/types.ts | 1 + src/gateway/server-methods-list.ts | 2 + .../server-methods/nodes.invoke-wake.test.ts | 183 ++++++++++++++ src/gateway/server-methods/nodes.ts | 228 +++++++++++++++++- 12 files changed, 604 insertions(+), 1 deletion(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 34b6876822bdf..499e50bab90a0 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -57,6 +57,7 @@ final class NodeAppModel { private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") + private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply") enum CameraHUDKind { @@ -130,6 +131,7 @@ final class NodeAppModel { private var backgroundReconnectLeaseUntil: Date? private var lastSignificantLocationWakeAt: Date? @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator() + private var pendingForegroundActionDrainInFlight = false private var gatewayConnected = false private var operatorConnected = false @@ -329,6 +331,9 @@ final class NodeAppModel { } await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive) } + Task { [weak self] in + await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active") + } } if phase == .active, self.reconnectAfterBackgroundArmed { self.reconnectAfterBackgroundArmed = false @@ -2098,6 +2103,22 @@ private extension NodeAppModel { } extension NodeAppModel { + private struct PendingForegroundNodeAction: Decodable { + var id: String + var command: String + var paramsJSON: String? + var enqueuedAtMs: Int? + } + + private struct PendingForegroundNodeActionsResponse: Decodable { + var nodeId: String? + var actions: [PendingForegroundNodeAction] + } + + private struct PendingForegroundNodeActionsAckRequest: Encodable { + var ids: [String] + } + private func refreshShareRouteFromGateway() async { struct Params: Codable { var includeGlobal: Bool @@ -2195,6 +2216,83 @@ extension NodeAppModel { func onNodeGatewayConnected() async { await self.registerAPNsTokenIfNeeded() await self.flushQueuedWatchRepliesIfConnected() + await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected") + } + + private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async { + guard !self.isBackgrounded else { return } + guard await self.isGatewayConnected() else { return } + guard !self.pendingForegroundActionDrainInFlight else { return } + + self.pendingForegroundActionDrainInFlight = true + defer { self.pendingForegroundActionDrainInFlight = false } + + do { + let payload = try await self.nodeGateway.request( + method: "node.pending.pull", + paramsJSON: "{}", + timeoutSeconds: 6) + let decoded = try JSONDecoder().decode( + PendingForegroundNodeActionsResponse.self, + from: payload) + guard !decoded.actions.isEmpty else { return } + self.pendingActionLogger.info( + "Pending actions pulled trigger=\(trigger, privacy: .public) " + + "count=\(decoded.actions.count, privacy: .public)") + await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger) + } catch { + // Best-effort only. + } + } + + private func applyPendingForegroundNodeActions( + _ actions: [PendingForegroundNodeAction], + trigger: String) async + { + for action in actions { + guard !self.isBackgrounded else { + self.pendingActionLogger.info( + "Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded") + return + } + let req = BridgeInvokeRequest( + id: action.id, + command: action.command, + paramsJSON: action.paramsJSON) + let result = await self.handleInvoke(req) + self.pendingActionLogger.info( + "Pending action replay trigger=\(trigger, privacy: .public) " + + "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) " + + "ok=\(result.ok, privacy: .public)") + guard result.ok else { return } + let acked = await self.ackPendingForegroundNodeAction( + id: action.id, + trigger: trigger, + command: action.command) + guard acked else { return } + } + } + + private func ackPendingForegroundNodeAction( + id: String, + trigger: String, + command: String) async -> Bool + { + do { + let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id])) + let paramsJSON = String(decoding: payload, as: UTF8.self) + _ = try await self.nodeGateway.request( + method: "node.pending.ack", + paramsJSON: paramsJSON, + timeoutSeconds: 6) + return true + } catch { + self.pendingActionLogger.error( + "Pending action ack failed trigger=\(trigger, privacy: .public) " + + "id=\(id, privacy: .public) command=\(command, privacy: .public) " + + "error=\(String(describing: error), privacy: .public)") + return false + } } private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { @@ -2843,6 +2941,19 @@ extension NodeAppModel { self.gatewayConnected = connected } + func _test_applyPendingForegroundNodeActions( + _ actions: [(id: String, command: String, paramsJSON: String?)]) async + { + let mapped = actions.map { action in + PendingForegroundNodeAction( + id: action.id, + command: action.command, + paramsJSON: action.paramsJSON, + enqueuedAtMs: nil) + } + await self.applyPendingForegroundNodeActions(mapped, trigger: "test") + } + static func _test_currentDeepLinkKey() -> String { self.expectedDeepLinkKey() } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 2875fa31339f3..7413b0295f908 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(payload?["result"] as? String == "2") } + @Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws { + let appModel = NodeAppModel() + let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") + let navData = try JSONEncoder().encode(navigateParams) + let navJSON = String(decoding: navData, as: UTF8.self) + + await appModel._test_applyPendingForegroundNodeActions([ + ( + id: "pending-nav-1", + command: OpenClawCanvasCommand.navigate.rawValue, + paramsJSON: navJSON + ), + ]) + + #expect(appModel.screen.urlString == "http://example.com/") + } + + @Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws { + let appModel = NodeAppModel() + appModel.setScenePhase(.background) + let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/") + let navData = try JSONEncoder().encode(navigateParams) + let navJSON = String(decoding: navData, as: UTF8.self) + + await appModel._test_applyPendingForegroundNodeActions([ + ( + id: "pending-nav-bg", + command: OpenClawCanvasCommand.navigate.rawValue, + paramsJSON: navJSON + ), + ]) + + #expect(appModel.screen.urlString.isEmpty) + } + @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { let appModel = NodeAppModel() diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 6aad2e9a9ac2c..ea44d030eb006 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable { public struct NodeListParams: Codable, Sendable {} +public struct NodePendingAckParams: Codable, Sendable { + public let ids: [String] + + public init( + ids: [String]) + { + self.ids = ids + } + + private enum CodingKeys: String, CodingKey { + case ids + } +} + public struct NodeDescribeParams: Codable, Sendable { public let nodeid: String diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 6aad2e9a9ac2c..ea44d030eb006 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable { public struct NodeListParams: Codable, Sendable {} +public struct NodePendingAckParams: Codable, Sendable { + public let ids: [String] + + public init( + ids: [String]) + { + self.ids = ids + } + + private enum CodingKeys: String, CodingKey { + case ids + } +} + public struct NodeDescribeParams: Codable, Sendable { public let nodeid: String diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 04f3b7565676c..91b20baacb0c7 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -23,6 +23,8 @@ const NODE_ROLE_METHODS = new Set([ "node.invoke.result", "node.event", "node.canvas.capability.refresh", + "node.pending.pull", + "node.pending.ack", "skills.bins", ]); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 7d3e5a8cb51be..95306f27f1262 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -146,6 +146,8 @@ import { NodeInvokeResultParamsSchema, type NodeListParams, NodeListParamsSchema, + type NodePendingAckParams, + NodePendingAckParamsSchema, type NodePairApproveParams, NodePairApproveParamsSchema, type NodePairListParams, @@ -285,6 +287,9 @@ export const validateNodePairVerifyParams = ajv.compile( ); export const validateNodeRenameParams = ajv.compile(NodeRenameParamsSchema); export const validateNodeListParams = ajv.compile(NodeListParamsSchema); +export const validateNodePendingAckParams = ajv.compile( + NodePendingAckParamsSchema, +); export const validateNodeDescribeParams = ajv.compile(NodeDescribeParamsSchema); export const validateNodeInvokeParams = ajv.compile(NodeInvokeParamsSchema); export const validateNodeInvokeResultParams = ajv.compile( @@ -465,6 +470,7 @@ export { NodePairRejectParamsSchema, NodePairVerifyParamsSchema, NodeListParamsSchema, + NodePendingAckParamsSchema, NodeInvokeParamsSchema, SessionsListParamsSchema, SessionsPreviewParamsSchema, diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index 4eaccb8d7fae1..7ce5a4fed0ae0 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -43,6 +43,13 @@ export const NodeRenameParamsSchema = Type.Object( export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false }); +export const NodePendingAckParamsSchema = Type.Object( + { + ids: Type.Array(NonEmptyString, { minItems: 1 }), + }, + { additionalProperties: false }, +); + export const NodeDescribeParamsSchema = Type.Object( { nodeId: NonEmptyString }, { additionalProperties: false }, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 0c55f5f292788..7ccd6cb2d1a06 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -118,6 +118,7 @@ import { NodeInvokeResultParamsSchema, NodeInvokeRequestEventSchema, NodeListParamsSchema, + NodePendingAckParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, NodePairRejectParamsSchema, @@ -180,6 +181,7 @@ export const ProtocolSchemas = { NodePairVerifyParams: NodePairVerifyParamsSchema, NodeRenameParams: NodeRenameParamsSchema, NodeListParams: NodeListParamsSchema, + NodePendingAckParams: NodePendingAckParamsSchema, NodeDescribeParams: NodeDescribeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeResultParams: NodeInvokeResultParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index f828bdbc418e6..cc15b80fd1a6b 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -27,6 +27,7 @@ export type NodePairRejectParams = SchemaType<"NodePairRejectParams">; export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">; export type NodeRenameParams = SchemaType<"NodeRenameParams">; export type NodeListParams = SchemaType<"NodeListParams">; +export type NodePendingAckParams = SchemaType<"NodePendingAckParams">; export type NodeDescribeParams = SchemaType<"NodeDescribeParams">; export type NodeInvokeParams = SchemaType<"NodeInvokeParams">; export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index c026492568c3a..5c5433ae2f7d8 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -77,6 +77,8 @@ const BASE_METHODS = [ "node.list", "node.describe", "node.invoke", + "node.pending.pull", + "node.pending.ack", "node.invoke.result", "node.event", "node.canvas.capability.refresh", diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 6e3ced97d6f98..1f606e925dcaa 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -49,6 +49,7 @@ type RespondCall = [ type TestNodeSession = { nodeId: string; commands: string[]; + platform?: string; }; const WAKE_WAIT_TIMEOUT_MS = 3_001; @@ -102,6 +103,54 @@ async function invokeNode(params: { return respond; } +async function pullPending(nodeId: string) { + const respond = vi.fn(); + await nodeHandlers["node.pending.pull"]({ + params: {}, + respond: respond as never, + context: {} as never, + client: { + connect: { + role: "node", + client: { + id: nodeId, + mode: "node", + name: "ios-test", + platform: "iOS 26.4.0", + version: "test", + }, + }, + } as never, + req: { type: "req", id: "req-node-pending", method: "node.pending.pull" }, + isWebchatConnect: () => false, + }); + return respond; +} + +async function ackPending(nodeId: string, ids: string[]) { + const respond = vi.fn(); + await nodeHandlers["node.pending.ack"]({ + params: { ids }, + respond: respond as never, + context: {} as never, + client: { + connect: { + role: "node", + client: { + id: nodeId, + mode: "node", + name: "ios-test", + platform: "iOS 26.4.0", + version: "test", + }, + }, + } as never, + req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" }, + isWebchatConnect: () => false, + }); + return respond; +} + function mockSuccessfulWakeConfig(nodeId: string) { mocks.loadApnsRegistration.mockResolvedValue({ nodeId, @@ -229,4 +278,138 @@ describe("node.invoke APNs wake path", () => { expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(2); expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); + + it("queues iOS foreground-only command failures and keeps them until acked", async () => { + mocks.loadApnsRegistration.mockResolvedValue(null); + + const nodeRegistry = { + get: vi.fn(() => ({ + nodeId: "ios-node-queued", + commands: ["canvas.navigate"], + platform: "iOS 26.4.0", + })), + invoke: vi.fn().mockResolvedValue({ + ok: false, + error: { + code: "NODE_BACKGROUND_UNAVAILABLE", + message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + }, + }), + }; + + const respond = await invokeNode({ + nodeRegistry, + requestParams: { + nodeId: "ios-node-queued", + command: "canvas.navigate", + params: { url: "http://example.com/" }, + idempotencyKey: "idem-queued", + }, + }); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE); + expect(call?.[2]?.message).toBe("node command queued until iOS returns to foreground"); + expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled(); + + const pullRespond = await pullPending("ios-node-queued"); + const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined; + expect(pullCall?.[0]).toBe(true); + expect(pullCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + actions: [ + expect.objectContaining({ + command: "canvas.navigate", + paramsJSON: JSON.stringify({ url: "http://example.com/" }), + }), + ], + }); + + const repeatedPullRespond = await pullPending("ios-node-queued"); + const repeatedPullCall = repeatedPullRespond.mock.calls[0] as RespondCall | undefined; + expect(repeatedPullCall?.[0]).toBe(true); + expect(repeatedPullCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + actions: [ + expect.objectContaining({ + command: "canvas.navigate", + paramsJSON: JSON.stringify({ url: "http://example.com/" }), + }), + ], + }); + + const queuedActionId = (pullCall?.[1] as { actions?: Array<{ id?: string }> } | undefined) + ?.actions?.[0]?.id; + expect(queuedActionId).toBeTruthy(); + + const ackRespond = await ackPending("ios-node-queued", [queuedActionId!]); + const ackCall = ackRespond.mock.calls[0] as RespondCall | undefined; + expect(ackCall?.[0]).toBe(true); + expect(ackCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + ackedIds: [queuedActionId], + remainingCount: 0, + }); + + const emptyPullRespond = await pullPending("ios-node-queued"); + const emptyPullCall = emptyPullRespond.mock.calls[0] as RespondCall | undefined; + expect(emptyPullCall?.[0]).toBe(true); + expect(emptyPullCall?.[1]).toMatchObject({ + nodeId: "ios-node-queued", + actions: [], + }); + }); + + it("dedupes queued foreground actions by idempotency key", async () => { + mocks.loadApnsRegistration.mockResolvedValue(null); + + const nodeRegistry = { + get: vi.fn(() => ({ + nodeId: "ios-node-dedupe", + commands: ["canvas.navigate"], + platform: "iPadOS 26.4.0", + })), + invoke: vi.fn().mockResolvedValue({ + ok: false, + error: { + code: "NODE_BACKGROUND_UNAVAILABLE", + message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground", + }, + }), + }; + + await invokeNode({ + nodeRegistry, + requestParams: { + nodeId: "ios-node-dedupe", + command: "canvas.navigate", + params: { url: "http://example.com/first" }, + idempotencyKey: "idem-dedupe", + }, + }); + await invokeNode({ + nodeRegistry, + requestParams: { + nodeId: "ios-node-dedupe", + command: "canvas.navigate", + params: { url: "http://example.com/first" }, + idempotencyKey: "idem-dedupe", + }, + }); + + const pullRespond = await pullPending("ios-node-dedupe"); + const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined; + expect(pullCall?.[0]).toBe(true); + expect(pullCall?.[1]).toMatchObject({ + nodeId: "ios-node-dedupe", + actions: [ + expect.objectContaining({ + command: "canvas.navigate", + paramsJSON: JSON.stringify({ url: "http://example.com/first" }), + }), + ], + }); + const actions = (pullCall?.[1] as { actions?: unknown[] } | undefined)?.actions ?? []; + expect(actions).toHaveLength(1); + }); }); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 848fa0dfea5f1..22e3c0912e4b5 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { loadConfig } from "../../config/config.js"; import { listDevicePairing } from "../../infra/device-pairing.js"; import { @@ -28,6 +29,7 @@ import { validateNodeEventParams, validateNodeInvokeParams, validateNodeListParams, + validateNodePendingAckParams, validateNodePairApproveParams, validateNodePairListParams, validateNodePairRejectParams, @@ -50,6 +52,8 @@ const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; const NODE_WAKE_RECONNECT_POLL_MS = 150; const NODE_WAKE_THROTTLE_MS = 15_000; const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000; +const NODE_PENDING_ACTION_TTL_MS = 10 * 60_000; +const NODE_PENDING_ACTION_MAX_PER_NODE = 64; type NodeWakeState = { lastWakeAtMs: number; @@ -77,6 +81,17 @@ type NodeWakeNudgeAttempt = { apnsReason?: string; }; +type PendingNodeAction = { + id: string; + nodeId: string; + command: string; + paramsJSON?: string; + idempotencyKey: string; + enqueuedAtMs: number; +}; + +const pendingNodeActionsById = new Map(); + function isNodeEntry(entry: { role?: string; roles?: string[] }) { if (entry.role === "node") { return true; @@ -91,6 +106,108 @@ async function delayMs(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function isForegroundRestrictedIosCommand(command: string): boolean { + return ( + command === "canvas.present" || + command === "canvas.navigate" || + command.startsWith("canvas.") || + command.startsWith("camera.") || + command.startsWith("screen.") || + command.startsWith("talk.") + ); +} + +function shouldQueueAsPendingForegroundAction(params: { + platform?: string; + command: string; + error: unknown; +}): boolean { + const platform = (params.platform ?? "").trim().toLowerCase(); + if (!platform.startsWith("ios") && !platform.startsWith("ipados")) { + return false; + } + if (!isForegroundRestrictedIosCommand(params.command)) { + return false; + } + const error = + params.error && typeof params.error === "object" + ? (params.error as { code?: unknown; message?: unknown }) + : null; + const code = typeof error?.code === "string" ? error.code.trim().toUpperCase() : ""; + const message = typeof error?.message === "string" ? error.message.trim().toUpperCase() : ""; + return code === "NODE_BACKGROUND_UNAVAILABLE" || message.includes("BACKGROUND_UNAVAILABLE"); +} + +function prunePendingNodeActions(nodeId: string, nowMs: number): PendingNodeAction[] { + const queue = pendingNodeActionsById.get(nodeId) ?? []; + const minTimestampMs = nowMs - NODE_PENDING_ACTION_TTL_MS; + const live = queue.filter((entry) => entry.enqueuedAtMs >= minTimestampMs); + if (live.length === 0) { + pendingNodeActionsById.delete(nodeId); + return []; + } + pendingNodeActionsById.set(nodeId, live); + return live; +} + +function enqueuePendingNodeAction(params: { + nodeId: string; + command: string; + paramsJSON?: string; + idempotencyKey: string; +}): PendingNodeAction { + const nowMs = Date.now(); + const queue = prunePendingNodeActions(params.nodeId, nowMs); + const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey); + if (existing) { + return existing; + } + const entry: PendingNodeAction = { + id: randomUUID(), + nodeId: params.nodeId, + command: params.command, + paramsJSON: params.paramsJSON, + idempotencyKey: params.idempotencyKey, + enqueuedAtMs: nowMs, + }; + queue.push(entry); + if (queue.length > NODE_PENDING_ACTION_MAX_PER_NODE) { + queue.splice(0, queue.length - NODE_PENDING_ACTION_MAX_PER_NODE); + } + pendingNodeActionsById.set(params.nodeId, queue); + return entry; +} + +function listPendingNodeActions(nodeId: string): PendingNodeAction[] { + return prunePendingNodeActions(nodeId, Date.now()); +} + +function ackPendingNodeActions(nodeId: string, ids: string[]): PendingNodeAction[] { + if (ids.length === 0) { + return listPendingNodeActions(nodeId); + } + const pending = prunePendingNodeActions(nodeId, Date.now()); + const idSet = new Set(ids); + const remaining = pending.filter((entry) => !idSet.has(entry.id)); + if (remaining.length === 0) { + pendingNodeActionsById.delete(nodeId); + return []; + } + pendingNodeActionsById.set(nodeId, remaining); + return remaining; +} + +function toPendingParamsJSON(params: unknown): string | undefined { + if (params === undefined) { + return undefined; + } + try { + return JSON.stringify(params); + } catch { + return undefined; + } +} + async function maybeWakeNodeWithApns( nodeId: string, opts?: { force?: boolean }, @@ -596,6 +713,66 @@ export const nodeHandlers: GatewayRequestHandlers = { undefined, ); }, + "node.pending.pull": async ({ params, respond, client }) => { + if (!validateNodeListParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.pull", + validator: validateNodeListParams, + }); + return; + } + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id; + const trimmedNodeId = String(nodeId ?? "").trim(); + if (!trimmedNodeId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); + return; + } + + const pending = listPendingNodeActions(trimmedNodeId); + respond( + true, + { + nodeId: trimmedNodeId, + actions: pending.map((entry) => ({ + id: entry.id, + command: entry.command, + paramsJSON: entry.paramsJSON ?? null, + enqueuedAtMs: entry.enqueuedAtMs, + })), + }, + undefined, + ); + }, + "node.pending.ack": async ({ params, respond, client }) => { + if (!validateNodePendingAckParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.ack", + validator: validateNodePendingAckParams, + }); + return; + } + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id; + const trimmedNodeId = String(nodeId ?? "").trim(); + if (!trimmedNodeId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); + return; + } + const ackIds = Array.from( + new Set((params.ids ?? []).map((value) => String(value ?? "").trim()).filter(Boolean)), + ); + const remaining = ackPendingNodeActions(trimmedNodeId, ackIds); + respond( + true, + { + nodeId: trimmedNodeId, + ackedIds: ackIds, + remainingCount: remaining.length, + }, + undefined, + ); + }, "node.invoke": async ({ params, respond, context, client, req }) => { if (!validateNodeInvokeParams(params)) { respondInvalidParams({ @@ -759,7 +936,56 @@ export const nodeHandlers: GatewayRequestHandlers = { timeoutMs: p.timeoutMs, idempotencyKey: p.idempotencyKey, }); - if (!respondUnavailableOnNodeInvokeError(respond, res)) { + if (!res.ok) { + if ( + shouldQueueAsPendingForegroundAction({ + platform: nodeSession.platform, + command, + error: res.error, + }) + ) { + const paramsJSON = toPendingParamsJSON(forwardedParams.params); + const queued = enqueuePendingNodeAction({ + nodeId, + command, + paramsJSON, + idempotencyKey: p.idempotencyKey, + }); + const wake = await maybeWakeNodeWithApns(nodeId); + context.logGateway.info( + `node pending queued node=${nodeId} req=${req.id} command=${command} ` + + `queuedId=${queued.id} wakePath=${wake.path} wakeAvailable=${wake.available}`, + ); + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "node command queued until iOS returns to foreground", + { + retryable: true, + details: { + code: "QUEUED_UNTIL_FOREGROUND", + queuedActionId: queued.id, + nodeId, + command, + wake: { + path: wake.path, + available: wake.available, + throttled: wake.throttled, + apnsStatus: wake.apnsStatus, + apnsReason: wake.apnsReason, + }, + nodeError: res.error ?? null, + }, + }, + ), + ); + return; + } + if (!respondUnavailableOnNodeInvokeError(respond, res)) { + return; + } return; } const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload; From abb8f63107c1094f0a96494a7ecbbdaed40be6f9 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:47:39 +0100 Subject: [PATCH 169/311] iOS: auto-load the scoped gateway canvas with safe fallback (#40282) Merged via squash. - mb-server validation: `swift test --package-path apps/shared/OpenClawKit --filter GatewayNodeSessionTests` - mb-server validation: `pnpm build` - Scope note: top-level `RootTabs` shell change was intentionally removed from this PR before merge --- .../Sources/Model/NodeAppModel+Canvas.swift | 68 ++++++++--- apps/ios/Sources/Model/NodeAppModel.swift | 20 ++-- .../OpenClawKit/GatewayNodeSession.swift | 107 +++++++++++++++++- .../GatewayNodeSessionTests.swift | 18 +++ 4 files changed, 185 insertions(+), 28 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 922757a65553c..73e13fa099255 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -1,9 +1,24 @@ import Foundation import Network import OpenClawKit -import os + +enum A2UIReadyState { + case ready(String) + case hostNotConfigured + case hostUnavailable +} extension NodeAppModel { + func resolveCanvasHostURL() async -> String? { + guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil } + if let host = base.host, LoopbackHost.isLoopback(host) { + return nil + } + return base.appendingPathComponent("__openclaw__/canvas/").absoluteString + } + func _test_resolveA2UIHostURL() async -> String? { await self.resolveA2UIHostURL() } @@ -19,22 +34,14 @@ extension NodeAppModel { } func showA2UIOnConnectIfNeeded() async { - guard let a2uiUrl = await self.resolveA2UIHostURL() else { - await MainActor.run { - self.lastAutoA2uiURL = nil - self.screen.showDefaultCanvas() - } - return - } let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines) if current.isEmpty || current == self.lastAutoA2uiURL { - // Avoid navigating the WKWebView to an unreachable host: it leaves a persistent - // "could not connect to the server" overlay even when the gateway is connected. - if let url = URL(string: a2uiUrl), + if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(), + let url = URL(string: canvasUrl), await Self.probeTCP(url: url, timeoutSeconds: 2.5) { - self.screen.navigate(to: a2uiUrl) - self.lastAutoA2uiURL = a2uiUrl + self.screen.navigate(to: canvasUrl) + self.lastAutoA2uiURL = canvasUrl } else { self.lastAutoA2uiURL = nil self.screen.showDefaultCanvas() @@ -42,11 +49,46 @@ extension NodeAppModel { } } + func ensureA2UIReadyWithCapabilityRefresh(timeoutMs: Int = 5000) async -> A2UIReadyState { + guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else { + return .hostNotConfigured + } + self.screen.navigate(to: initialUrl) + if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { + return .ready(initialUrl) + } + + // First render can fail when scoped capability rotates between reconnects. + guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable } + guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable } + self.screen.navigate(to: refreshedUrl) + if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { + return .ready(refreshedUrl) + } + return .hostUnavailable + } + func showLocalCanvasOnDisconnect() { self.lastAutoA2uiURL = nil self.screen.showDefaultCanvas() } + private func resolveA2UIHostURLWithCapabilityRefresh() async -> String? { + if let url = await self.resolveA2UIHostURL() { + return url + } + guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil } + return await self.resolveA2UIHostURL() + } + + private func resolveCanvasHostURLWithCapabilityRefresh() async -> String? { + if let url = await self.resolveCanvasHostURL() { + return url + } + guard await self.gatewaySession.refreshNodeCanvasCapability() else { return nil } + return await self.resolveCanvasHostURL() + } + private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool { guard let host = url.host, !host.isEmpty else { return false } let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 499e50bab90a0..e5a8c2161616d 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -882,16 +882,17 @@ final class NodeAppModel { let command = req.command switch command { case OpenClawCanvasA2UICommand.reset.rawValue: - guard let a2uiUrl = await self.resolveA2UIHostURL() else { + switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { + case .ready: + break + case .hostNotConfigured: return BridgeInvokeResponse( id: req.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) - } - self.screen.navigate(to: a2uiUrl) - if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { + case .hostUnavailable: return BridgeInvokeResponse( id: req.id, ok: false, @@ -899,7 +900,6 @@ final class NodeAppModel { code: .unavailable, message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable")) } - let json = try await self.screen.eval(javaScript: """ (() => { const host = globalThis.openclawA2UI; @@ -908,6 +908,7 @@ final class NodeAppModel { })() """) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue: let messages: [OpenClawKit.AnyCodable] if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue { @@ -924,16 +925,17 @@ final class NodeAppModel { } } - guard let a2uiUrl = await self.resolveA2UIHostURL() else { + switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) { + case .ready: + break + case .hostNotConfigured: return BridgeInvokeResponse( id: req.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host")) - } - self.screen.navigate(to: a2uiUrl) - if await !self.screen.waitForA2UIReady(timeoutMs: 5000) { + case .hostUnavailable: return BridgeInvokeResponse( id: req.id, ok: false, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index a3c09ff35040b..378ad10e36504 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -11,6 +11,50 @@ private struct NodeInvokeRequestPayload: Codable, Sendable { var idempotencyKey: String? } +private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capability: String) -> String? { + let marker = "/__openclaw__/cap/" + guard let markerRange = scopedUrl.range(of: marker) else { return nil } + let capabilityStart = markerRange.upperBound + let suffix = scopedUrl[capabilityStart...] + let nextSlash = suffix.firstIndex(of: "/") + let nextQuery = suffix.firstIndex(of: "?") + let nextFragment = suffix.firstIndex(of: "#") + let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex + guard capabilityStart < capabilityEnd else { return nil } + return String(scopedUrl[.. String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + guard var parsed = URLComponents(string: trimmed) else { return trimmed } + + let parsedHost = parsed.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedIsLoopback = !parsedHost.isEmpty && LoopbackHost.isLoopback(parsedHost) + + if !parsedHost.isEmpty, !parsedIsLoopback { + guard let activeURL else { return trimmed } + let isTLS = activeURL.scheme?.lowercased() == "wss" + guard isTLS else { return trimmed } + parsed.scheme = "https" + if parsed.port == nil { + let tlsPort = activeURL.port ?? 443 + parsed.port = (tlsPort == 443) ? nil : tlsPort + } + return parsed.string ?? trimmed + } + + guard let activeURL, let fallbackHost = activeURL.host, !LoopbackHost.isLoopback(fallbackHost) else { + return trimmed + } + let isTLS = activeURL.scheme?.lowercased() == "wss" + parsed.scheme = isTLS ? "https" : "http" + parsed.host = fallbackHost + let fallbackPort = activeURL.port ?? (isTLS ? 443 : 80) + parsed.port = ((isTLS && fallbackPort == 443) || (!isTLS && fallbackPort == 80)) ? nil : fallbackPort + return parsed.string ?? trimmed +} + public actor GatewayNodeSession { private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") @@ -223,6 +267,46 @@ public actor GatewayNodeSession { self.canvasHostUrl } + public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool { + guard let channel = self.channel else { return false } + do { + let data = try await channel.request( + method: "node.canvas.capability.refresh", + params: [:], + timeoutMs: Double(max(timeoutMs, 1))) + guard + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawCapability = payload["canvasCapability"] as? String + else { + self.logger.warning("node.canvas.capability.refresh missing canvasCapability") + return false + } + let capability = rawCapability.trimmingCharacters(in: .whitespacesAndNewlines) + guard !capability.isEmpty else { + self.logger.warning("node.canvas.capability.refresh returned empty capability") + return false + } + let scopedUrl = self.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !scopedUrl.isEmpty else { + self.logger.warning("node.canvas.capability.refresh missing local canvasHostUrl") + return false + } + guard let refreshed = replaceCanvasCapabilityInScopedHostUrl( + scopedUrl: scopedUrl, + capability: capability) + else { + self.logger.warning("node.canvas.capability.refresh could not rewrite scoped canvas URL") + return false + } + self.canvasHostUrl = refreshed + return true + } catch { + self.logger.warning( + "node.canvas.capability.refresh failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + public func currentRemoteAddress() -> String? { guard let url = self.activeURL else { return nil } guard let host = url.host else { return url.absoluteString } @@ -275,7 +359,7 @@ public actor GatewayNodeSession { switch push { case let .snapshot(ok): let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) - self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil + self.canvasHostUrl = self.normalizeCanvasHostUrl(raw) if self.hasEverConnected { self.broadcastServerEvent( EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) @@ -342,6 +426,10 @@ public actor GatewayNodeSession { await self.onConnected?() } + private func normalizeCanvasHostUrl(_ raw: String?) -> String? { + canonicalizeCanvasHostUrl(raw: raw, activeURL: self.activeURL) + } + private func handleEvent(_ evt: EventFrame) async { self.broadcastServerEvent(evt) guard evt.event == "node.invoke.request" else { return } @@ -350,16 +438,21 @@ public actor GatewayNodeSession { do { let request = try self.decodeInvokeRequest(from: payload) let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" - self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") + self.logger.info( + "node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") guard let onInvoke else { return } - let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) + let req = BridgeInvokeRequest( + id: request.id, + command: request.command, + paramsJSON: request.paramsJSON) self.logger.info("node invoke executing id=\(request.id, privacy: .public)") let response = await Self.invokeWithTimeout( request: req, timeoutMs: request.timeoutMs, onInvoke: onInvoke ) - self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + self.logger.info( + "node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") await self.sendInvokeResult(request: request, response: response) } catch { self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") @@ -380,7 +473,8 @@ public actor GatewayNodeSession { private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async { guard let channel = self.channel else { return } - self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") + self.logger.info( + "node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") var params: [String: AnyCodable] = [ "id": AnyCodable(request.id), "nodeId": AnyCodable(request.nodeId), @@ -398,7 +492,8 @@ public actor GatewayNodeSession { do { try await channel.send(method: "node.invoke.result", params: params) } catch { - self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + self.logger.error( + "node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index a706e4bdb4cd9..a48015e1100e1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -169,6 +169,24 @@ private actor SeqGapProbe { } struct GatewayNodeSessionTests { + @Test + func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() { + let normalized = canonicalizeCanvasHostUrl( + raw: "https://canvas.example.com:9443/__openclaw__/cap/token", + activeURL: URL(string: "wss://gateway.example.com")!) + + #expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token") + } + + @Test + func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() { + let normalized = canonicalizeCanvasHostUrl( + raw: "http://127.0.0.1:18789/__openclaw__/cap/token", + activeURL: URL(string: "wss://gateway.example.com:7443")!) + + #expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token") + } + @Test func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async { let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil) From da6592b6815e50f6080525005a8c8222ea54e5cc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 15:34:56 -0700 Subject: [PATCH 170/311] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d20ca2541a374..45c8dd8163402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Docs: https://docs.openclaw.ai +## Unreleeased + +### Changes + +### Breaking + +### Fixes + ## 2026.3.8 ### Changes From 3ea3a1c0ca09708dbe1328ae0ee9c62b32df4db0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 15:35:13 -0700 Subject: [PATCH 171/311] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c8dd8163402..b25193bd9da09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Docs: https://docs.openclaw.ai -## Unreleeased +## Unreleased ### Changes From bd1fe4d8b4a0d139be051cc1132a4d0ed5cf4796 Mon Sep 17 00:00:00 2001 From: langdon Date: Sun, 8 Mar 2026 18:53:09 -0400 Subject: [PATCH 172/311] fix(run-openclaw-podman): add SELinux :Z mount option on enforcing/permissive hosts (#39449) * fix(run-openclaw-podman): add SELinux :Z mount option on Linux with enforcing/permissive SELinux * fix(quadlet): add SELinux :Z label to openclaw.container.in volume mount * fix(podman): add SELinux :Z mount option for Fedora/RHEL hosts Co-Authored-By: Claude Opus 4.6 Signed-off-by: sallyom --------- Signed-off-by: sallyom Co-authored-by: sallyom Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 1 + scripts/podman/openclaw.container.in | 2 +- scripts/run-openclaw-podman.sh | 24 ++++++++++++++++++++---- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b25193bd9da09..3d4b50a361e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. - macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. +- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. ## 2026.3.7 diff --git a/scripts/podman/openclaw.container.in b/scripts/podman/openclaw.container.in index db643ca42bc28..e0ad2ac8bdeae 100644 --- a/scripts/podman/openclaw.container.in +++ b/scripts/podman/openclaw.container.in @@ -11,7 +11,7 @@ ContainerName=openclaw UserNS=keep-id # Keep container UID/GID aligned with the invoking user so mounted config is readable. User=%U:%G -Volume={{OPENCLAW_HOME}}/.openclaw:/home/node/.openclaw +Volume={{OPENCLAW_HOME}}/.openclaw:/home/node/.openclaw:Z EnvironmentFile={{OPENCLAW_HOME}}/.openclaw/.env Environment=HOME=/home/node Environment=TERM=xterm-256color diff --git a/scripts/run-openclaw-podman.sh b/scripts/run-openclaw-podman.sh index 33e9f6d7d94fc..68b64915479ea 100755 --- a/scripts/run-openclaw-podman.sh +++ b/scripts/run-openclaw-podman.sh @@ -183,14 +183,30 @@ fi ENV_FILE_ARGS=() [[ -f "$ENV_FILE" ]] && ENV_FILE_ARGS+=(--env-file "$ENV_FILE") +# On Linux with SELinux enforcing/permissive, add ,Z so Podman relabels the +# bind-mounted directories and the container can access them. +SELINUX_MOUNT_OPTS="" +if [[ -z "${OPENCLAW_BIND_MOUNT_OPTIONS:-}" ]]; then + if [[ "$(uname -s 2>/dev/null)" == "Linux" ]] && command -v getenforce >/dev/null 2>&1; then + _selinux_mode="$(getenforce 2>/dev/null || true)" + if [[ "$_selinux_mode" == "Enforcing" || "$_selinux_mode" == "Permissive" ]]; then + SELINUX_MOUNT_OPTS=",Z" + fi + fi +else + # Honour explicit override (e.g. OPENCLAW_BIND_MOUNT_OPTIONS=":Z" → strip leading colon for inline use). + SELINUX_MOUNT_OPTS="${OPENCLAW_BIND_MOUNT_OPTIONS#:}" + [[ -n "$SELINUX_MOUNT_OPTS" ]] && SELINUX_MOUNT_OPTS=",$SELINUX_MOUNT_OPTS" +fi + if [[ "$RUN_SETUP" == true ]]; then exec podman run --pull="$PODMAN_PULL" --rm -it \ --init \ "${USERNS_ARGS[@]}" "${RUN_USER_ARGS[@]}" \ -e HOME=/home/node -e TERM=xterm-256color -e BROWSER=echo \ -e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ - -v "$CONFIG_DIR:/home/node/.openclaw:rw" \ - -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \ + -v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \ + -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \ "${ENV_FILE_ARGS[@]}" \ "$OPENCLAW_IMAGE" \ node dist/index.js onboard "$@" @@ -203,8 +219,8 @@ podman run --pull="$PODMAN_PULL" -d --replace \ -e HOME=/home/node -e TERM=xterm-256color \ -e OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ "${ENV_FILE_ARGS[@]}" \ - -v "$CONFIG_DIR:/home/node/.openclaw:rw" \ - -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw" \ + -v "$CONFIG_DIR:/home/node/.openclaw:rw${SELINUX_MOUNT_OPTS}" \ + -v "$WORKSPACE_DIR:/home/node/.openclaw/workspace:rw${SELINUX_MOUNT_OPTS}" \ -p "${HOST_GATEWAY_PORT}:18789" \ -p "${HOST_BRIDGE_PORT}:18790" \ "$OPENCLAW_IMAGE" \ From 3f3f66a5f75bffa86b3ff0c42ebf914f4a089dd6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 16:07:04 -0700 Subject: [PATCH 173/311] Docker: trim runtime image payload (#40307) * Docker: shrink runtime image payload * Docker: add runtime pnpm opt-in * Docker: collapse helper entrypoint chmod layers * Docker: restore bundled pnpm runtime * Update CHANGELOG.md --- CHANGELOG.md | 2 +- Dockerfile | 55 ++++++++++++-------- Dockerfile.sandbox-browser | 3 +- package.json | 1 + scripts/docker/cleanup-smoke/Dockerfile | 3 +- scripts/docker/install-sh-e2e/Dockerfile | 3 +- scripts/docker/install-sh-nonroot/Dockerfile | 3 +- scripts/docker/install-sh-smoke/Dockerfile | 3 +- src/docker-setup.e2e.test.ts | 2 +- src/dockerfile.test.ts | 17 ++++++ 10 files changed, 57 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4b50a361e85..c1463d7bc1602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Breaking ### Fixes +- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. ## 2026.3.8 @@ -46,7 +47,6 @@ Docs: https://docs.openclaw.ai - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. - macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. -- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. ## 2026.3.7 diff --git a/Dockerfile b/Dockerfile index 6b147441e5ee8..f1d7163d1920e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,15 @@ RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile COPY . . +# Normalize extension paths now so runtime COPY preserves safe modes +# without adding a second full extensions layer. +RUN for dir in /app/extensions /app/.agent /app/.agents; do \ + if [ -d "$dir" ]; then \ + find "$dir" -type d -exec chmod 755 {} +; \ + find "$dir" -type f -exec chmod 644 {} +; \ + fi; \ + done + # A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64 # on Apple Silicon). CI builds natively per-arch so this is a no-op there. # Stub it so local cross-arch builds still succeed. @@ -67,11 +76,17 @@ RUN pnpm canvas:a2ui:bundle || \ echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \ echo "stub" > src/canvas-host/a2ui/.bundle.hash && \ rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) -RUN pnpm build +RUN pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm ui:build +# Prune dev dependencies and strip build-only metadata before copying +# runtime assets into the final image. +FROM build AS runtime-assets +RUN CI=true pnpm prune --prod && \ + find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete + # ── Runtime base images ───────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default ARG OPENCLAW_NODE_BOOKWORM_DIGEST @@ -110,19 +125,22 @@ RUN apt-get update && \ RUN chown node:node /app -COPY --from=build --chown=node:node /app/dist ./dist -COPY --from=build --chown=node:node /app/node_modules ./node_modules -COPY --from=build --chown=node:node /app/package.json . -COPY --from=build --chown=node:node /app/openclaw.mjs . -COPY --from=build --chown=node:node /app/extensions ./extensions -COPY --from=build --chown=node:node /app/skills ./skills -COPY --from=build --chown=node:node /app/docs ./docs - -# Docker live-test runners invoke `pnpm` inside the runtime image. -# Activate the exact pinned package manager now so the container does not -# rely on a first-run network fetch or missing shims under the non-root user. -RUN corepack enable && \ - corepack prepare "$(node -p "require('./package.json').packageManager")" --activate +COPY --from=runtime-assets --chown=node:node /app/dist ./dist +COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules +COPY --from=runtime-assets --chown=node:node /app/package.json . +COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs . +COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions +COPY --from=runtime-assets --chown=node:node /app/skills ./skills +COPY --from=runtime-assets --chown=node:node /app/docs ./docs + +# Keep pnpm available in the runtime image for container-local workflows. +# Use a shared Corepack home so the non-root `node` user does not need a +# first-run network fetch when invoking pnpm. +ENV COREPACK_HOME=/usr/local/share/corepack +RUN install -d -m 0755 "$COREPACK_HOME" && \ + corepack enable && \ + corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \ + chmod -R a+rX "$COREPACK_HOME" # Install additional system packages needed by your skills or extensions. # Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . @@ -182,15 +200,6 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -# Normalize extension paths so plugin safety checks do not reject -# world-writable directories inherited from source file modes. -RUN for dir in /app/extensions /app/.agent /app/.agents; do \ - if [ -d "$dir" ]; then \ - find "$dir" -type d -exec chmod 755 {} +; \ - find "$dir" -type f -exec chmod 644 {} +; \ - fi; \ - done - # Expose the CLI binary without requiring npm global writes as non-root. RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ && chmod 755 /app/openclaw.mjs diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index ec9faf7111314..78b0de98904b5 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -20,8 +20,7 @@ RUN apt-get update \ xvfb \ && rm -rf /var/lib/apt/lists/* -COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser -RUN chmod +x /usr/local/bin/openclaw-sandbox-browser +COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser RUN useradd --create-home --shell /bin/bash sandbox USER sandbox diff --git a/package.json b/package.json index 93692b174ae47..753fe15a0598e 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,7 @@ "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && 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-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && 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-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 1d9288b0df567..34ce3327ac7b9 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -13,7 +13,6 @@ RUN corepack enable \ && pnpm install --frozen-lockfile COPY . . -COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke -RUN chmod +x /usr/local/bin/openclaw-cleanup-smoke +COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"] diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index 26b69b0b7ef4c..839d637a04b49 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -9,8 +9,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY run.sh /usr/local/bin/openclaw-install-e2e -RUN chmod +x /usr/local/bin/openclaw-install-e2e +COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e RUN useradd --create-home --shell /bin/bash appuser USER appuser diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 5543ef84882da..9b7912323a440 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -28,7 +28,6 @@ ENV NPM_CONFIG_AUDIT=false COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot -RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot +COPY --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"] diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index ee3221607fbc3..eb2dcfe522622 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -20,7 +20,6 @@ RUN set -eux; \ COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke -RUN chmod +x /usr/local/bin/openclaw-install-smoke +COPY --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"] diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 6fd42e256edf2..6890e7d55a8fd 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -163,7 +163,7 @@ describe("docker-setup.sh", () => { sandbox = null; }); - it("handles env defaults, home-volume mounts, and apt build args", async () => { + it("handles env defaults, home-volume mounts, and Docker build args", async () => { const activeSandbox = requireSandbox(sandbox); const result = runDockerSetup(activeSandbox, { diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4dd41398e5acc..a23b7e8e0839e 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -37,6 +37,15 @@ describe("Dockerfile", () => { expect(dockerfile).toContain("apt-get install -y --no-install-recommends xvfb"); }); + it("prunes runtime dependencies after the build stage", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("FROM build AS runtime-assets"); + expect(dockerfile).toContain("CI=true pnpm prune --prod"); + expect(dockerfile).toContain( + "COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules", + ); + }); + it("normalizes plugin and agent paths permissions in image layers", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("for dir in /app/extensions /app/.agent /app/.agents"); @@ -49,4 +58,12 @@ describe("Dockerfile", () => { expect(dockerfile).toContain('== "fpr" {'); expect(dockerfile).not.toContain('\\"fpr\\"'); }); + + it("keeps runtime pnpm available", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("ENV COREPACK_HOME=/usr/local/share/corepack"); + expect(dockerfile).toContain( + 'corepack prepare "$(node -p "require(\'./package.json\').packageManager")" --activate', + ); + }); }); From 211f68f8ad1b3ca25a8dbbf391d2a6ede3be8d17 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 16:11:53 -0700 Subject: [PATCH 174/311] docs(changelog): move post-2026.3.8 entries to unreleased (#40342) * docs(changelog): move post-2026.3.8 entries to unreleased * Update CHANGELOG.md --- CHANGELOG.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1463d7bc1602..905f285df5196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,6 @@ Docs: https://docs.openclaw.ai -## Unreleased - -### Changes - -### Breaking - -### Fixes -- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. - ## 2026.3.8 ### Changes @@ -24,8 +15,11 @@ Docs: https://docs.openclaw.ai - CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. - CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. +### Breaking + ### Fixes +- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. - Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092) - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. @@ -47,6 +41,7 @@ Docs: https://docs.openclaw.ai - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. - macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. +- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. ## 2026.3.7 From a3dc4b5a577599eddb636cd307baf32bb43146fc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 16:17:28 -0700 Subject: [PATCH 175/311] fix(tui): improve color contrast for light-background terminals (#40345) * fix(tui): improve colour contrast for light-background terminals (#38636) Detect light terminal backgrounds via COLORFGBG and apply a WCAG AA-compliant light palette. Adds OPENCLAW_THEME=light|dark env var override for terminals without auto-detection. Uses proper sRGB linearisation and WCAG 2.1 contrast ratios to pick whichever text palette (dark or light) has higher contrast against the detected background colour. Co-authored-by: ademczuk * Update CHANGELOG.md --------- Co-authored-by: ademczuk Co-authored-by: ademczuk --- CHANGELOG.md | 1 + docs/help/environment.md | 6 + docs/web/tui.md | 6 + src/tui/theme/syntax-theme.ts | 50 +++++++- src/tui/theme/theme.test.ts | 222 +++++++++++++++++++++++++++++++++- src/tui/theme/theme.ts | 101 +++++++++++++++- 6 files changed, 381 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905f285df5196..72ac0fab2cecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. - Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. +- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. ## 2026.3.7 diff --git a/docs/help/environment.md b/docs/help/environment.md index 7fa1fdfa6c53a..860129bde3729 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -68,6 +68,12 @@ OpenClaw also injects context markers into spawned child processes: These are runtime markers (not required user config). They can be used in shell/profile logic to apply context-specific rules. +## UI env vars + +- `OPENCLAW_THEME=light`: force the light TUI palette when your terminal has a light background. +- `OPENCLAW_THEME=dark`: force the dark TUI palette. +- `COLORFGBG`: if your terminal exports it, OpenClaw uses the background color hint to auto-pick the TUI palette. + ## Env var substitution in config You can reference env vars directly in config string values using `${VAR_NAME}` syntax: diff --git a/docs/web/tui.md b/docs/web/tui.md index 1553fd5d66823..0c09cb1f8777d 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -122,6 +122,12 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate - Ctrl+O toggles between collapsed/expanded views. - While tools run, partial updates stream into the same card. +## Terminal colors + +- The TUI keeps assistant body text in your terminal's default foreground so dark and light terminals both stay readable. +- If your terminal uses a light background and auto-detection is wrong, set `OPENCLAW_THEME=light` before launching `openclaw tui`. +- To force the original dark palette instead, set `OPENCLAW_THEME=dark`. + ## History + streaming - On connect, the TUI loads the latest history (default 200 messages). diff --git a/src/tui/theme/syntax-theme.ts b/src/tui/theme/syntax-theme.ts index ba29d5012db60..d0aea2d5a9c44 100644 --- a/src/tui/theme/syntax-theme.ts +++ b/src/tui/theme/syntax-theme.ts @@ -6,7 +6,55 @@ type HighlightTheme = Record string>; * Syntax highlighting theme for code blocks. * Uses chalk functions to style different token types. */ -export function createSyntaxTheme(fallback: (text: string) => string): HighlightTheme { +export function createSyntaxTheme( + fallback: (text: string) => string, + light = false, +): HighlightTheme { + if (light) { + return { + keyword: chalk.hex("#AF00DB"), + built_in: chalk.hex("#267F99"), + type: chalk.hex("#267F99"), + literal: chalk.hex("#0000FF"), + number: chalk.hex("#098658"), + string: chalk.hex("#A31515"), + regexp: chalk.hex("#811F3F"), + symbol: chalk.hex("#098658"), + class: chalk.hex("#267F99"), + function: chalk.hex("#795E26"), + title: chalk.hex("#795E26"), + params: chalk.hex("#001080"), + comment: chalk.hex("#008000"), + doctag: chalk.hex("#008000"), + meta: chalk.hex("#001080"), + "meta-keyword": chalk.hex("#AF00DB"), + "meta-string": chalk.hex("#A31515"), + section: chalk.hex("#795E26"), + tag: chalk.hex("#800000"), + name: chalk.hex("#001080"), + attr: chalk.hex("#C50000"), + attribute: chalk.hex("#C50000"), + variable: chalk.hex("#001080"), + bullet: chalk.hex("#795E26"), + code: chalk.hex("#A31515"), + emphasis: chalk.italic, + strong: chalk.bold, + formula: chalk.hex("#AF00DB"), + link: chalk.hex("#267F99"), + quote: chalk.hex("#008000"), + addition: chalk.hex("#098658"), + deletion: chalk.hex("#A31515"), + "selector-tag": chalk.hex("#800000"), + "selector-id": chalk.hex("#800000"), + "selector-class": chalk.hex("#800000"), + "selector-attr": chalk.hex("#800000"), + "selector-pseudo": chalk.hex("#800000"), + "template-tag": chalk.hex("#AF00DB"), + "template-variable": chalk.hex("#001080"), + default: fallback, + }; + } + return { keyword: chalk.hex("#C586C0"), // purple - if, const, function, etc. built_in: chalk.hex("#4EC9B0"), // teal - console, Math, etc. diff --git a/src/tui/theme/theme.test.ts b/src/tui/theme/theme.test.ts index dd692304599cb..50aa349b68990 100644 --- a/src/tui/theme/theme.test.ts +++ b/src/tui/theme/theme.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cliHighlightMocks = vi.hoisted(() => ({ highlight: vi.fn((code: string) => code), @@ -13,6 +13,25 @@ const { markdownTheme, searchableSelectListTheme, selectListTheme, theme } = const stripAnsi = (str: string) => str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), ""); +function relativeLuminance(hex: string): number { + const channels = hex + .replace("#", "") + .match(/.{2}/g) + ?.map((part) => Number.parseInt(part, 16) / 255) + .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); + if (!channels || channels.length !== 3) { + throw new Error(`invalid color: ${hex}`); + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function contrastRatio(foreground: string, background: string): number { + const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)].toSorted( + (a, b) => b - a, + ); + return (lighter + 0.05) / (darker + 0.05); +} + describe("markdownTheme", () => { describe("highlightCode", () => { beforeEach(() => { @@ -61,6 +80,207 @@ describe("theme", () => { }); }); +describe("light background detection", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.resetModules(); + }); + + async function importThemeWithEnv(env: Record) { + vi.resetModules(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return import("./theme.js"); + } + + it("uses dark palette by default", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: undefined, + }); + expect(mod.lightMode).toBe(false); + }); + + it("selects light palette when OPENCLAW_THEME=light", async () => { + const mod = await importThemeWithEnv({ OPENCLAW_THEME: "light" }); + expect(mod.lightMode).toBe(true); + }); + + it("selects dark palette when OPENCLAW_THEME=dark", async () => { + const mod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" }); + expect(mod.lightMode).toBe(false); + }); + + it("treats OPENCLAW_THEME case-insensitively", async () => { + const mod = await importThemeWithEnv({ OPENCLAW_THEME: "LiGhT" }); + expect(mod.lightMode).toBe(true); + }); + + it("detects light background from COLORFGBG", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;15", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats COLORFGBG bg=7 (silver) as light", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;7", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats COLORFGBG bg=8 (bright black / dark gray) as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;8", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats COLORFGBG bg < 7 as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;0", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats 256-color COLORFGBG bg=232 (near-black greyscale) as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;232", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats 256-color COLORFGBG bg=255 (near-white greyscale) as light", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;255", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats 256-color COLORFGBG bg=231 (white cube entry) as light", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;231", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats 256-color COLORFGBG bg=16 (black cube entry) as dark", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;16", + }); + expect(mod.lightMode).toBe(false); + }); + + it("treats bright 256-color green backgrounds as light when dark text contrasts better", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;34", + }); + expect(mod.lightMode).toBe(true); + }); + + it("treats bright 256-color cyan backgrounds as light when dark text contrasts better", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "15;39", + }); + expect(mod.lightMode).toBe(true); + }); + + it("falls back to dark mode for invalid COLORFGBG values", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "garbage", + }); + expect(mod.lightMode).toBe(false); + }); + + it("ignores pathological COLORFGBG values", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: undefined, + COLORFGBG: "0;".repeat(40), + }); + expect(mod.lightMode).toBe(false); + }); + + it("OPENCLAW_THEME overrides COLORFGBG", async () => { + const mod = await importThemeWithEnv({ + OPENCLAW_THEME: "dark", + COLORFGBG: "0;15", + }); + expect(mod.lightMode).toBe(false); + }); + + it("keeps assistantText as identity in both modes", async () => { + const lightMod = await importThemeWithEnv({ OPENCLAW_THEME: "light" }); + const darkMod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" }); + expect(lightMod.theme.assistantText("hello")).toBe("hello"); + expect(darkMod.theme.assistantText("hello")).toBe("hello"); + }); +}); + +describe("light palette accessibility", () => { + it("keeps light theme text colors at WCAG AA contrast or better", async () => { + vi.resetModules(); + process.env.OPENCLAW_THEME = "light"; + const mod = await import("./theme.js"); + const backgrounds = { + page: "#FFFFFF", + user: mod.lightPalette.userBg, + pending: mod.lightPalette.toolPendingBg, + success: mod.lightPalette.toolSuccessBg, + error: mod.lightPalette.toolErrorBg, + code: mod.lightPalette.codeBlock, + }; + + const textPairs = [ + [mod.lightPalette.text, backgrounds.page], + [mod.lightPalette.dim, backgrounds.page], + [mod.lightPalette.accent, backgrounds.page], + [mod.lightPalette.accentSoft, backgrounds.page], + [mod.lightPalette.systemText, backgrounds.page], + [mod.lightPalette.link, backgrounds.page], + [mod.lightPalette.quote, backgrounds.page], + [mod.lightPalette.error, backgrounds.page], + [mod.lightPalette.success, backgrounds.page], + [mod.lightPalette.userText, backgrounds.user], + [mod.lightPalette.dim, backgrounds.pending], + [mod.lightPalette.dim, backgrounds.success], + [mod.lightPalette.dim, backgrounds.error], + [mod.lightPalette.toolTitle, backgrounds.pending], + [mod.lightPalette.toolTitle, backgrounds.success], + [mod.lightPalette.toolTitle, backgrounds.error], + [mod.lightPalette.toolOutput, backgrounds.pending], + [mod.lightPalette.toolOutput, backgrounds.success], + [mod.lightPalette.toolOutput, backgrounds.error], + [mod.lightPalette.code, backgrounds.code], + [mod.lightPalette.border, backgrounds.page], + [mod.lightPalette.quoteBorder, backgrounds.page], + [mod.lightPalette.codeBorder, backgrounds.page], + ] as const; + + for (const [foreground, background] of textPairs) { + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5); + } + }); +}); + describe("list themes", () => { it("reuses shared select-list styles in searchable list theme", () => { expect(searchableSelectListTheme.selectedPrefix(">")).toBe(selectListTheme.selectedPrefix(">")); diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 9b2f1ad27c737..1af4154095ea9 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -9,7 +9,76 @@ import { highlight, supportsLanguage } from "cli-highlight"; import type { SearchableSelectListTheme } from "../components/searchable-select-list.js"; import { createSyntaxTheme } from "./syntax-theme.js"; -const palette = { +const DARK_TEXT = "#E8E3D5"; +const LIGHT_TEXT = "#1E1E1E"; +const XTERM_LEVELS = [0, 95, 135, 175, 215, 255] as const; + +function channelToSrgb(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminanceRgb(r: number, g: number, b: number): number { + const red = channelToSrgb(r); + const green = channelToSrgb(g); + const blue = channelToSrgb(b); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function relativeLuminanceHex(hex: string): number { + return relativeLuminanceRgb( + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), + ); +} + +function contrastRatio(background: number, foregroundHex: string): number { + const foreground = relativeLuminanceHex(foregroundHex); + const lighter = Math.max(background, foreground); + const darker = Math.min(background, foreground); + return (lighter + 0.05) / (darker + 0.05); +} + +function pickHigherContrastText(r: number, g: number, b: number): boolean { + const background = relativeLuminanceRgb(r, g, b); + return contrastRatio(background, LIGHT_TEXT) >= contrastRatio(background, DARK_TEXT); +} + +function isLightBackground(): boolean { + const explicit = process.env.OPENCLAW_THEME?.toLowerCase(); + if (explicit === "light") { + return true; + } + if (explicit === "dark") { + return false; + } + + const colorfgbg = process.env.COLORFGBG; + if (colorfgbg && colorfgbg.length <= 64) { + const sep = colorfgbg.lastIndexOf(";"); + const bg = Number.parseInt(sep >= 0 ? colorfgbg.slice(sep + 1) : colorfgbg, 10); + if (bg >= 0 && bg <= 255) { + if (bg <= 15) { + return bg === 7 || bg === 15; + } + if (bg >= 232) { + return bg >= 244; + } + const cubeIndex = bg - 16; + const bVal = XTERM_LEVELS[cubeIndex % 6]; + const gVal = XTERM_LEVELS[Math.floor(cubeIndex / 6) % 6]; + const rVal = XTERM_LEVELS[Math.floor(cubeIndex / 36)]; + return pickHigherContrastText(rVal, gVal, bVal); + } + } + return false; +} + +/** Whether the terminal has a light background. Exported for testing only. */ +export const lightMode = isLightBackground(); + +export const darkPalette = { text: "#E8E3D5", dim: "#7B7F87", accent: "#F6C453", @@ -31,12 +100,38 @@ const palette = { link: "#7DD3A5", error: "#F97066", success: "#7DD3A5", -}; +} as const; + +export const lightPalette = { + text: "#1E1E1E", + dim: "#5B6472", + accent: "#B45309", + accentSoft: "#C2410C", + border: "#5B6472", + userBg: "#F3F0E8", + userText: "#1E1E1E", + systemText: "#4B5563", + toolPendingBg: "#EFF6FF", + toolSuccessBg: "#ECFDF5", + toolErrorBg: "#FEF2F2", + toolTitle: "#B45309", + toolOutput: "#374151", + quote: "#1D4ED8", + quoteBorder: "#2563EB", + code: "#92400E", + codeBlock: "#F9FAFB", + codeBorder: "#92400E", + link: "#047857", + error: "#DC2626", + success: "#047857", +} as const; + +export const palette = lightMode ? lightPalette : darkPalette; const fg = (hex: string) => (text: string) => chalk.hex(hex)(text); const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text); -const syntaxTheme = createSyntaxTheme(fg(palette.code)); +const syntaxTheme = createSyntaxTheme(fg(palette.code), lightMode); /** * Highlight code with syntax coloring. From b2b99f0325fcef9afe52cc10bb01aae3b9350c5d Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Sun, 8 Mar 2026 21:34:52 +0300 Subject: [PATCH 176/311] fix(models): keep --all aligned with synthetic catalog rows --- .../list.list-command.forward-compat.test.ts | 132 ++++++++++++++++++ src/commands/models/list.list-command.ts | 42 ++++++ 2 files changed, 174 insertions(+) diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 4cef137d88adb..a480156f21851 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -38,6 +38,7 @@ const mocks = vi.hoisted(() => { loadModelRegistry: vi .fn() .mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }), + loadModelCatalog: vi.fn().mockResolvedValue([]), resolveConfiguredEntries: vi.fn().mockReturnValue({ entries: [ { @@ -77,6 +78,10 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { }; }); +vi.mock("../../agents/model-catalog.js", () => ({ + loadModelCatalog: mocks.loadModelCatalog, +})); + vi.mock("./list.registry.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -198,6 +203,133 @@ describe("modelsListCommand forward-compat", () => { ); }); + it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [ + { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), + registry: {}, + }); + mocks.loadModelCatalog.mockResolvedValueOnce([ + { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + input: ["text"], + contextWindow: 272000, + }, + { + provider: "openai-codex", + id: "gpt-5.4", + name: "GPT-5.4", + input: ["text"], + contextWindow: 272000, + }, + ]); + mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) => + provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], + ); + mocks.resolveModelWithRegistry.mockImplementation( + ({ provider, modelId }: { provider: string; modelId: string }) => { + if (provider !== "openai-codex") { + return undefined; + } + if (modelId === "gpt-5.3-codex") { + return { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + } + if (modelId === "gpt-5.4") { + return { + provider: "openai-codex", + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + } + return undefined; + }, + ); + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ + key: string; + available: boolean; + }>; + + expect(rows).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.3-codex", + }), + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ]); + }); + + it("keeps discovered rows in --all output when catalog lookup is empty", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [ + { + provider: "openai-codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), + registry: {}, + }); + mocks.loadModelCatalog.mockResolvedValueOnce([]); + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>; + + expect(rows).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.3-codex", + }), + ]); + }); + it("exits with an error when configured-mode listing has no model registry", async () => { vi.clearAllMocks(); const previousExitCode = process.exitCode; diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index acb6c95761f0a..c19d18d9d11fd 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,5 +1,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; import { parseModelRef } from "../../agents/model-selection.js"; import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -69,6 +70,7 @@ export async function modelsListCommand( const rows: ModelRow[] = []; if (opts.all) { + const seenKeys = new Set(); const sorted = [...models].toSorted((a, b) => { const p = a.provider.localeCompare(b.provider); if (p !== 0) { @@ -97,6 +99,46 @@ export async function modelsListCommand( authStore, }), ); + seenKeys.add(key); + } + + if (modelRegistry) { + const catalog = await loadModelCatalog({ config: cfg }); + for (const entry of catalog) { + if (providerFilter && entry.provider.toLowerCase() !== providerFilter) { + continue; + } + const key = modelKey(entry.provider, entry.id); + if (seenKeys.has(key)) { + continue; + } + const model = resolveModelWithRegistry({ + provider: entry.provider, + modelId: entry.id, + modelRegistry, + cfg, + }); + if (!model) { + continue; + } + if (opts.local && !isLocalBaseUrl(model.baseUrl)) { + continue; + } + const configured = configuredByKey.get(key); + rows.push( + toModelRow({ + model, + key, + tags: configured ? Array.from(configured.tags) : [], + aliases: configured?.aliases ?? [], + availableKeys, + cfg, + authStore, + allowProviderAvailabilityFallback: !discoveredKeys.has(key), + }), + ); + seenKeys.add(key); + } } } else { const registry = modelRegistry; From 3da8882a02489ec49cc920b9f6fc1f832d93188a Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Sun, 8 Mar 2026 22:43:57 +0300 Subject: [PATCH 177/311] test(models): refresh list assertions after main sync --- src/commands/models.list.e2e.test.ts | 4 +- .../list.list-command.forward-compat.test.ts | 75 +++++++++++-------- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 171893134a1e4..e7d55e00b3c3d 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -354,8 +354,8 @@ describe("models list/status", () => { await modelsListCommand({ all: true, json: true }, runtime); - expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); - expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig); + expect(ensureOpenClawModelsJson).toHaveBeenCalled(); + expect(ensureOpenClawModelsJson.mock.calls[0]?.[0]).toEqual(resolvedConfig); }); it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index a480156f21851..d33ceb2aab18d 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -67,6 +67,8 @@ const mocks = vi.hoisted(() => { vi.mock("../../config/config.js", () => ({ loadConfig: mocks.loadConfig, + getRuntimeConfigSnapshot: vi.fn().mockReturnValue(null), + getRuntimeConfigSourceSnapshot: vi.fn().mockReturnValue(null), })); vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { @@ -182,25 +184,29 @@ describe("modelsListCommand forward-compat", () => { availableKeys: new Set(), registry: {}, }); - mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) => + mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], ); const runtime = { log: vi.fn(), error: vi.fn() }; - await modelsListCommand({ json: true }, runtime as never); + try { + await modelsListCommand({ json: true }, runtime as never); - expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ - key: string; - available: boolean; - }>; + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ + key: string; + available: boolean; + }>; - expect(rows).toContainEqual( - expect.objectContaining({ - key: "openai-codex/gpt-5.4", - available: true, - }), - ); + expect(rows).toContainEqual( + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ); + } finally { + mocks.listProfilesForProvider.mockReturnValue([]); + } }); it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { @@ -238,7 +244,7 @@ describe("modelsListCommand forward-compat", () => { contextWindow: 272000, }, ]); - mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) => + mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], ); mocks.resolveModelWithRegistry.mockImplementation( @@ -277,23 +283,30 @@ describe("modelsListCommand forward-compat", () => { ); const runtime = { log: vi.fn(), error: vi.fn() }; - await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never); - - expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ - key: string; - available: boolean; - }>; - - expect(rows).toEqual([ - expect.objectContaining({ - key: "openai-codex/gpt-5.3-codex", - }), - expect.objectContaining({ - key: "openai-codex/gpt-5.4", - available: true, - }), - ]); + try { + await modelsListCommand( + { all: true, provider: "openai-codex", json: true }, + runtime as never, + ); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ + key: string; + available: boolean; + }>; + + expect(rows).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.3-codex", + }), + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ]); + } finally { + mocks.listProfilesForProvider.mockReturnValue([]); + } }); it("keeps discovered rows in --all output when catalog lookup is empty", async () => { From 024857050a7f6f6ecd4aebb804bbf92bf63c6da7 Mon Sep 17 00:00:00 2001 From: 0xsline Date: Sat, 7 Mar 2026 17:48:17 +0800 Subject: [PATCH 178/311] fix: normalize openai-codex gpt-5.4 transport overrides --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/model.test.ts | 54 ++++++++ src/agents/pi-embedded-runner/model.ts | 140 ++++++++++++++------ 3 files changed, 157 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ac0fab2cecb..74953047f5344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. +- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. - Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 0dfde212a0a56..e67fb2c289823 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -664,6 +664,60 @@ describe("resolveModel", () => { }); }); + it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => { + mockOpenAICodexTemplateModel(); + + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + }, + }, + }, + } as unknown as OpenClawConfig; + + expectResolvedForwardCompatFallback({ + provider: "openai-codex", + id: "gpt-5.4", + cfg, + expectedModel: { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + id: "gpt-5.4", + provider: "openai-codex", + }, + }); + }); + + it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => { + mockOpenAICodexTemplateModel(); + + const cfg: OpenClawConfig = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + }, + }, + }, + } as unknown as OpenClawConfig; + + expectResolvedForwardCompatFallback({ + provider: "openai-codex", + id: "gpt-5.4", + cfg, + expectedModel: { + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + id: "gpt-5.4", + provider: "openai-codex", + }, + }); + }); + it("includes auth hint for unknown ollama models (#17328)", () => { // resetMockDiscoverModels() in beforeEach already sets find → null const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent"); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 38d554a2bab90..5995bb4009938 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -23,6 +23,8 @@ type InlineProviderConfig = { headers?: unknown; }; +const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + function sanitizeModelHeaders( headers: unknown, opts?: { stripSecretRefMarkers?: boolean }, @@ -43,6 +45,60 @@ function sanitizeModelHeaders( return Object.keys(next).length > 0 ? next : undefined; } +function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +function isOpenAICodexBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed); +} + +function normalizeOpenAICodexTransport(params: { + provider: string; + model: Model; +}): Model { + if (normalizeProviderId(params.provider) !== "openai-codex") { + return params.model; + } + + const useCodexTransport = + !params.model.baseUrl || + isOpenAIApiBaseUrl(params.model.baseUrl) || + isOpenAICodexBaseUrl(params.model.baseUrl); + + const nextApi = + useCodexTransport && params.model.api === "openai-responses" + ? ("openai-codex-responses" as const) + : params.model.api; + const nextBaseUrl = + nextApi === "openai-codex-responses" && + (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)) + ? OPENAI_CODEX_BASE_URL + : params.model.baseUrl; + + if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) { + return params.model; + } + + return { + ...params.model, + api: nextApi, + baseUrl: nextBaseUrl, + } as Model; +} + +function normalizeResolvedModel(params: { provider: string; model: Model }): Model { + return normalizeModelCompat(normalizeOpenAICodexTransport(params)); +} + export { buildModelAliasLines }; function resolveConfiguredProviderConfig( @@ -145,13 +201,14 @@ export function resolveModelWithRegistry(params: { const model = modelRegistry.find(provider, modelId) as Model | null; if (model) { - return normalizeModelCompat( - applyConfiguredProviderOverrides({ + return normalizeResolvedModel({ + provider, + model: applyConfiguredProviderOverrides({ discoveredModel: model, providerConfig, modelId, }), - ); + }); } const providers = cfg?.models?.providers ?? {}; @@ -161,64 +218,71 @@ export function resolveModelWithRegistry(params: { (entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId, ); if (inlineMatch?.api) { - return normalizeModelCompat(inlineMatch as Model); + return normalizeResolvedModel({ provider, model: inlineMatch as Model }); } // Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback. // Otherwise, configured providers can default to a generic API and break specific transports. const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry); if (forwardCompat) { - return normalizeModelCompat( - applyConfiguredProviderOverrides({ + return normalizeResolvedModel({ + provider, + model: applyConfiguredProviderOverrides({ discoveredModel: forwardCompat, providerConfig, modelId, }), - ); + }); } // OpenRouter is a pass-through proxy - any model ID available on OpenRouter // should work without being pre-registered in the local catalog. if (normalizedProvider === "openrouter") { - return normalizeModelCompat({ - id: modelId, - name: modelId, - api: "openai-completions", + return normalizeResolvedModel({ provider, - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts - maxTokens: 8192, - } as Model); + model: { + id: modelId, + name: modelId, + api: "openai-completions", + provider, + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_TOKENS, + // Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts + maxTokens: 8192, + } as Model, + }); } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); if (providerConfig || modelId.startsWith("mock-")) { - return normalizeModelCompat({ - id: modelId, - name: modelId, - api: providerConfig?.api ?? "openai-responses", + return normalizeResolvedModel({ provider, - baseUrl: providerConfig?.baseUrl, - reasoning: configuredModel?.reasoning ?? false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: - configuredModel?.contextWindow ?? - providerConfig?.models?.[0]?.contextWindow ?? - DEFAULT_CONTEXT_TOKENS, - maxTokens: - configuredModel?.maxTokens ?? - providerConfig?.models?.[0]?.maxTokens ?? - DEFAULT_CONTEXT_TOKENS, - headers: - providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, - } as Model); + model: { + id: modelId, + name: modelId, + api: providerConfig?.api ?? "openai-responses", + provider, + baseUrl: providerConfig?.baseUrl, + reasoning: configuredModel?.reasoning ?? false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: + configuredModel?.contextWindow ?? + providerConfig?.models?.[0]?.contextWindow ?? + DEFAULT_CONTEXT_TOKENS, + maxTokens: + configuredModel?.maxTokens ?? + providerConfig?.models?.[0]?.maxTokens ?? + DEFAULT_CONTEXT_TOKENS, + headers: + providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, + } as Model, + }); } return undefined; From 3e70109cb218cf79a5f77bb17da0959d41ebdead Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:39:53 +0000 Subject: [PATCH 179/311] docs: add refactor cluster backlog --- docs/refactor/cluster.md | 299 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/refactor/cluster.md diff --git a/docs/refactor/cluster.md b/docs/refactor/cluster.md new file mode 100644 index 0000000000000..f3b13186972da --- /dev/null +++ b/docs/refactor/cluster.md @@ -0,0 +1,299 @@ +--- +summary: "Refactor clusters with highest LOC reduction potential" +read_when: + - You want to reduce total LOC without changing behavior + - You are choosing the next dedupe or extraction pass +title: "Refactor Cluster Backlog" +--- + +# Refactor Cluster Backlog + +Ranked by likely LOC reduction, safety, and breadth. + +## 1. Channel plugin config and security scaffolding + +Highest-value cluster. + +Repeated shapes across many channel plugins: + +- `config.listAccountIds` +- `config.resolveAccount` +- `config.defaultAccountId` +- `config.setAccountEnabled` +- `config.deleteAccount` +- `config.describeAccount` +- `security.resolveDmPolicy` + +Strong examples: + +- `extensions/telegram/src/channel.ts` +- `extensions/googlechat/src/channel.ts` +- `extensions/slack/src/channel.ts` +- `extensions/discord/src/channel.ts` +- `extensions/matrix/src/channel.ts` +- `extensions/irc/src/channel.ts` +- `extensions/signal/src/channel.ts` +- `extensions/mattermost/src/channel.ts` + +Likely extraction shape: + +- `buildChannelConfigAdapter(...)` +- `buildMultiAccountConfigAdapter(...)` +- `buildDmSecurityAdapter(...)` + +Expected savings: + +- ~250-450 LOC + +Risk: + +- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization. + +## 2. Extension runtime singleton boilerplate + +Very safe. + +Nearly every extension has the same runtime holder: + +- `let runtime: PluginRuntime | null = null` +- `setXRuntime` +- `getXRuntime` + +Strong examples: + +- `extensions/telegram/src/runtime.ts` +- `extensions/matrix/src/runtime.ts` +- `extensions/slack/src/runtime.ts` +- `extensions/discord/src/runtime.ts` +- `extensions/whatsapp/src/runtime.ts` +- `extensions/imessage/src/runtime.ts` +- `extensions/twitch/src/runtime.ts` + +Special-case variants: + +- `extensions/bluebubbles/src/runtime.ts` +- `extensions/line/src/runtime.ts` +- `extensions/synology-chat/src/runtime.ts` + +Likely extraction shape: + +- `createPluginRuntimeStore(errorMessage)` + +Expected savings: + +- ~180-260 LOC + +Risk: + +- Low + +## 3. Onboarding prompt and config-patch steps + +Large surface area. + +Many onboarding files repeat: + +- resolve account id +- prompt allowlist entries +- merge allowFrom +- set DM policy +- prompt secrets +- patch top-level vs account-scoped config + +Strong examples: + +- `extensions/bluebubbles/src/onboarding.ts` +- `extensions/googlechat/src/onboarding.ts` +- `extensions/msteams/src/onboarding.ts` +- `extensions/zalo/src/onboarding.ts` +- `extensions/zalouser/src/onboarding.ts` +- `extensions/nextcloud-talk/src/onboarding.ts` +- `extensions/matrix/src/onboarding.ts` +- `extensions/irc/src/onboarding.ts` + +Existing helper seam: + +- `src/channels/plugins/onboarding/helpers.ts` + +Likely extraction shape: + +- `promptAllowFromList(...)` +- `buildDmPolicyAdapter(...)` +- `applyScopedAccountPatch(...)` +- `promptSecretFields(...)` + +Expected savings: + +- ~300-600 LOC + +Risk: + +- Medium. Easy to over-generalize; keep helpers narrow and composable. + +## 4. Multi-account config-schema fragments + +Repeated schema fragments across extensions. + +Common patterns: + +- `const allowFromEntry = z.union([z.string(), z.number()])` +- account schema plus: + - `accounts: z.object({}).catchall(accountSchema).optional()` + - `defaultAccount: z.string().optional()` +- repeated DM/group fields +- repeated markdown/tool policy fields + +Strong examples: + +- `extensions/bluebubbles/src/config-schema.ts` +- `extensions/zalo/src/config-schema.ts` +- `extensions/zalouser/src/config-schema.ts` +- `extensions/matrix/src/config-schema.ts` +- `extensions/nostr/src/config-schema.ts` + +Likely extraction shape: + +- `AllowFromEntrySchema` +- `buildMultiAccountChannelSchema(accountSchema)` +- `buildCommonDmGroupFields(...)` + +Expected savings: + +- ~120-220 LOC + +Risk: + +- Low to medium. Some schemas are simple, some are special. + +## 5. Webhook and monitor lifecycle startup + +Good medium-value cluster. + +Repeated `startAccount` / monitor setup patterns: + +- resolve account +- compute webhook path +- log startup +- start monitor +- wait for abort +- cleanup +- status sink updates + +Strong examples: + +- `extensions/googlechat/src/channel.ts` +- `extensions/bluebubbles/src/channel.ts` +- `extensions/zalo/src/channel.ts` +- `extensions/telegram/src/channel.ts` +- `extensions/nextcloud-talk/src/channel.ts` + +Existing helper seam: + +- `src/plugin-sdk/channel-lifecycle.ts` + +Likely extraction shape: + +- helper for account monitor lifecycle +- helper for webhook-backed account startup + +Expected savings: + +- ~150-300 LOC + +Risk: + +- Medium to high. Transport details diverge quickly. + +## 6. Small exact-clone cleanup + +Low-risk cleanup bucket. + +Examples: + +- duplicated gateway argv detection: + - `src/infra/gateway-lock.ts` + - `src/cli/daemon-cli/lifecycle.ts` +- duplicated port diagnostics rendering: + - `src/cli/daemon-cli/restart-health.ts` +- duplicated session-key construction: + - `src/web/auto-reply/monitor/broadcast.ts` + +Expected savings: + +- ~30-60 LOC + +Risk: + +- Low + +## Test clusters + +### LINE webhook event fixtures + +Strong examples: + +- `src/line/bot-handlers.test.ts` + +Likely extraction: + +- `makeLineEvent(...)` +- `runLineEvent(...)` +- `makeLineAccount(...)` + +Expected savings: + +- ~120-180 LOC + +### Telegram native command auth matrix + +Strong examples: + +- `src/telegram/bot-native-commands.group-auth.test.ts` +- `src/telegram/bot-native-commands.plugin-auth.test.ts` + +Likely extraction: + +- forum context builder +- denied-message assertion helper +- table-driven auth cases + +Expected savings: + +- ~80-140 LOC + +### Zalo lifecycle setup + +Strong examples: + +- `extensions/zalo/src/monitor.lifecycle.test.ts` + +Likely extraction: + +- shared monitor setup harness + +Expected savings: + +- ~50-90 LOC + +### Brave llm-context unsupported-option tests + +Strong examples: + +- `src/agents/tools/web-tools.enabled-defaults.test.ts` + +Likely extraction: + +- `it.each(...)` matrix + +Expected savings: + +- ~30-50 LOC + +## Suggested order + +1. Runtime singleton boilerplate +2. Small exact-clone cleanup +3. Config and security builder extraction +4. Test-helper extraction +5. Onboarding step extraction +6. Monitor lifecycle helper extraction From 8d7778d1d6c56c85311d5d8de5a2658a181e0083 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:40:17 +0000 Subject: [PATCH 180/311] refactor: dedupe plugin runtime stores --- extensions/bluebubbles/src/runtime.ts | 19 +++++++---------- extensions/discord/src/runtime.ts | 16 ++++----------- extensions/feishu/src/runtime.ts | 16 ++++----------- extensions/googlechat/src/runtime.ts | 16 ++++----------- extensions/imessage/src/runtime.ts | 16 ++++----------- extensions/irc/src/runtime.ts | 16 ++++----------- extensions/line/src/runtime.ts | 16 ++++----------- extensions/matrix/src/runtime.ts | 16 ++++----------- extensions/mattermost/src/runtime.ts | 16 ++++----------- extensions/msteams/src/runtime.ts | 16 ++++----------- extensions/nextcloud-talk/src/runtime.ts | 16 ++++----------- extensions/nostr/src/runtime.ts | 16 ++++----------- extensions/signal/src/runtime.ts | 16 ++++----------- extensions/slack/src/runtime.ts | 16 ++++----------- extensions/synology-chat/src/runtime.ts | 24 ++++++---------------- extensions/telegram/src/runtime.ts | 16 ++++----------- extensions/tlon/src/runtime.ts | 16 ++++----------- extensions/twitch/src/runtime.ts | 16 ++++----------- extensions/whatsapp/src/runtime.ts | 16 ++++----------- extensions/zalo/src/runtime.ts | 16 ++++----------- extensions/zalouser/src/runtime.ts | 16 ++++----------- src/plugin-sdk/index.ts | 1 + src/plugin-sdk/runtime-store.ts | 26 ++++++++++++++++++++++++ 23 files changed, 116 insertions(+), 258 deletions(-) create mode 100644 src/plugin-sdk/runtime-store.ts diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index 89ee04cf8a4c2..e1c0254e1c08e 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,31 +1,26 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; -let runtime: PluginRuntime | null = null; +const runtimeStore = createPluginRuntimeStore("BlueBubbles runtime not initialized"); type LegacyRuntimeLogShape = { log?: (message: string) => void }; - -export function setBlueBubblesRuntime(next: PluginRuntime): void { - runtime = next; -} +export const setBlueBubblesRuntime = runtimeStore.setRuntime; export function clearBlueBubblesRuntime(): void { - runtime = null; + runtimeStore.clearRuntime(); } export function tryGetBlueBubblesRuntime(): PluginRuntime | null { - return runtime; + return runtimeStore.tryGetRuntime(); } export function getBlueBubblesRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("BlueBubbles runtime not initialized"); - } - return runtime; + return runtimeStore.getRuntime(); } export function warnBlueBubbles(message: string): void { const formatted = `[bluebubbles] ${message}`; // Backward-compatible with tests/legacy injections that pass { log }. - const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log; + const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log; if (typeof log === "function") { log(formatted); return; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 506a81085ee88..9a23266edda5a 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/discord"; -let runtime: PluginRuntime | null = null; - -export function setDiscordRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getDiscordRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Discord runtime not initialized"); - } - return runtime; -} +const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = + createPluginRuntimeStore("Discord runtime not initialized"); +export { getDiscordRuntime, setDiscordRuntime }; diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index b66579e87752a..c1a4b65c50a50 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/feishu"; -let runtime: PluginRuntime | null = null; - -export function setFeishuRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getFeishuRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Feishu runtime not initialized"); - } - return runtime; -} +const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } = + createPluginRuntimeStore("Feishu runtime not initialized"); +export { getFeishuRuntime, setFeishuRuntime }; diff --git a/extensions/googlechat/src/runtime.ts b/extensions/googlechat/src/runtime.ts index 55af03db04d22..2276eb7dcfa52 100644 --- a/extensions/googlechat/src/runtime.ts +++ b/extensions/googlechat/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat"; -let runtime: PluginRuntime | null = null; - -export function setGoogleChatRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getGoogleChatRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Google Chat runtime not initialized"); - } - return runtime; -} +const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } = + createPluginRuntimeStore("Google Chat runtime not initialized"); +export { getGoogleChatRuntime, setGoogleChatRuntime }; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 866d9c8d38012..a4b2f1a98de3f 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/imessage"; -let runtime: PluginRuntime | null = null; - -export function setIMessageRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getIMessageRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("iMessage runtime not initialized"); - } - return runtime; -} +const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = + createPluginRuntimeStore("iMessage runtime not initialized"); +export { getIMessageRuntime, setIMessageRuntime }; diff --git a/extensions/irc/src/runtime.ts b/extensions/irc/src/runtime.ts index 51fcdd7c45480..b5597236b7a0c 100644 --- a/extensions/irc/src/runtime.ts +++ b/extensions/irc/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/irc"; -let runtime: PluginRuntime | null = null; - -export function setIrcRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getIrcRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("IRC runtime not initialized"); - } - return runtime; -} +const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } = + createPluginRuntimeStore("IRC runtime not initialized"); +export { getIrcRuntime, setIrcRuntime }; diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 4f1a4fc121abc..38ed57e7875b3 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/line"; -let runtime: PluginRuntime | null = null; - -export function setLineRuntime(r: PluginRuntime): void { - runtime = r; -} - -export function getLineRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("LINE runtime not initialized - plugin not registered"); - } - return runtime; -} +const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } = + createPluginRuntimeStore("LINE runtime not initialized - plugin not registered"); +export { getLineRuntime, setLineRuntime }; diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index 4d94aacf99def..90fe7d1f8e9ba 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; -let runtime: PluginRuntime | null = null; - -export function setMatrixRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMatrixRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Matrix runtime not initialized"); - } - return runtime; -} +const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = + createPluginRuntimeStore("Matrix runtime not initialized"); +export { getMatrixRuntime, setMatrixRuntime }; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index f6e5e83f270aa..8fe131f233531 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost"; -let runtime: PluginRuntime | null = null; - -export function setMattermostRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMattermostRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Mattermost runtime not initialized"); - } - return runtime; -} +const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = + createPluginRuntimeStore("Mattermost runtime not initialized"); +export { getMattermostRuntime, setMattermostRuntime }; diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts index 97d2272c1015b..04444a29fc1db 100644 --- a/extensions/msteams/src/runtime.ts +++ b/extensions/msteams/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/msteams"; -let runtime: PluginRuntime | null = null; - -export function setMSTeamsRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMSTeamsRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("MSTeams runtime not initialized"); - } - return runtime; -} +const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } = + createPluginRuntimeStore("MSTeams runtime not initialized"); +export { getMSTeamsRuntime, setMSTeamsRuntime }; diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index 2a7718e1661b9..d4870a7483920 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk"; -let runtime: PluginRuntime | null = null; - -export function setNextcloudTalkRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getNextcloudTalkRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Nextcloud Talk runtime not initialized"); - } - return runtime; -} +const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = + createPluginRuntimeStore("Nextcloud Talk runtime not initialized"); +export { getNextcloudTalkRuntime, setNextcloudTalkRuntime }; diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index dbcffde4979c8..1063bd8d6d305 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; -let runtime: PluginRuntime | null = null; - -export function setNostrRuntime(next: PluginRuntime): void { - runtime = next; -} - -export function getNostrRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Nostr runtime not initialized"); - } - return runtime; -} +const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = + createPluginRuntimeStore("Nostr runtime not initialized"); +export { getNostrRuntime, setNostrRuntime }; diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index 21f90071ad8dd..fd6c5fbdae6cc 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/signal"; -let runtime: PluginRuntime | null = null; - -export function setSignalRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getSignalRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Signal runtime not initialized"); - } - return runtime; -} +const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = + createPluginRuntimeStore("Signal runtime not initialized"); +export { getSignalRuntime, setSignalRuntime }; diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 02222d2b073ca..9ba83fcb4c820 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/slack"; -let runtime: PluginRuntime | null = null; - -export function setSlackRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getSlackRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Slack runtime not initialized"); - } - return runtime; -} +const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = + createPluginRuntimeStore("Slack runtime not initialized"); +export { getSlackRuntime, setSlackRuntime }; diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index f7ef39ff65fdc..6abb71d8188dc 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,20 +1,8 @@ -/** - * Plugin runtime singleton. - * Stores the PluginRuntime from api.runtime (set during register()). - * Used by channel.ts to access dispatch functions. - */ - +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat"; -let runtime: PluginRuntime | null = null; - -export function setSynologyRuntime(r: PluginRuntime): void { - runtime = r; -} - -export function getSynologyRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Synology Chat runtime not initialized - plugin not registered"); - } - return runtime; -} +const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = + createPluginRuntimeStore( + "Synology Chat runtime not initialized - plugin not registered", + ); +export { getSynologyRuntime, setSynologyRuntime }; diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index dd1e3f9f2b8e3..4effcb7b5bf69 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/telegram"; -let runtime: PluginRuntime | null = null; - -export function setTelegramRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getTelegramRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Telegram runtime not initialized"); - } - return runtime; -} +const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = + createPluginRuntimeStore("Telegram runtime not initialized"); +export { getTelegramRuntime, setTelegramRuntime }; diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index 0400d636b578e..1551ea38f3f3e 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/tlon"; -let runtime: PluginRuntime | null = null; - -export function setTlonRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getTlonRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Tlon runtime not initialized"); - } - return runtime; -} +const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = + createPluginRuntimeStore("Tlon runtime not initialized"); +export { getTlonRuntime, setTlonRuntime }; diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index 5dfdd225c4c9b..f82e4313f81f2 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/twitch"; -let runtime: PluginRuntime | null = null; - -export function setTwitchRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getTwitchRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Twitch runtime not initialized"); - } - return runtime; -} +const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = + createPluginRuntimeStore("Twitch runtime not initialized"); +export { getTwitchRuntime, setTwitchRuntime }; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 490c7873219b9..c5044db6a2906 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; -let runtime: PluginRuntime | null = null; - -export function setWhatsAppRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getWhatsAppRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("WhatsApp runtime not initialized"); - } - return runtime; -} +const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = + createPluginRuntimeStore("WhatsApp runtime not initialized"); +export { getWhatsAppRuntime, setWhatsAppRuntime }; diff --git a/extensions/zalo/src/runtime.ts b/extensions/zalo/src/runtime.ts index 5d96660a7d308..7454204391360 100644 --- a/extensions/zalo/src/runtime.ts +++ b/extensions/zalo/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalo"; -let runtime: PluginRuntime | null = null; - -export function setZaloRuntime(next: PluginRuntime): void { - runtime = next; -} - -export function getZaloRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Zalo runtime not initialized"); - } - return runtime; -} +const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = + createPluginRuntimeStore("Zalo runtime not initialized"); +export { getZaloRuntime, setZaloRuntime }; diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts index 42cb9def444bb..473df2b8fbef5 100644 --- a/extensions/zalouser/src/runtime.ts +++ b/extensions/zalouser/src/runtime.ts @@ -1,14 +1,6 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser"; -let runtime: PluginRuntime | null = null; - -export function setZalouserRuntime(next: PluginRuntime): void { - runtime = next; -} - -export function getZalouserRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Zalouser runtime not initialized"); - } - return runtime; -} +const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } = + createPluginRuntimeStore("Zalouser runtime not initialized"); +export { getZalouserRuntime, setZalouserRuntime }; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ca3f54a479bcd..816d644cd991e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -194,6 +194,7 @@ export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { ChannelSendRawResult } from "./channel-send-result.js"; +export { createPluginRuntimeStore } from "./runtime-store.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; diff --git a/src/plugin-sdk/runtime-store.ts b/src/plugin-sdk/runtime-store.ts new file mode 100644 index 0000000000000..de0d84131e131 --- /dev/null +++ b/src/plugin-sdk/runtime-store.ts @@ -0,0 +1,26 @@ +export function createPluginRuntimeStore(errorMessage: string): { + setRuntime: (next: T) => void; + clearRuntime: () => void; + tryGetRuntime: () => T | null; + getRuntime: () => T; +} { + let runtime: T | null = null; + + return { + setRuntime(next: T) { + runtime = next; + }, + clearRuntime() { + runtime = null; + }, + tryGetRuntime() { + return runtime; + }, + getRuntime() { + if (!runtime) { + throw new Error(errorMessage); + } + return runtime; + }, + }; +} From 32a6eae576e5e535a0db88d1c71ed7e277d8c5d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:41:05 +0000 Subject: [PATCH 181/311] refactor: share gateway argv parsing --- src/cli/daemon-cli/lifecycle.ts | 41 +++---------------------------- src/infra/gateway-lock.ts | 33 +------------------------ src/infra/gateway-process-argv.ts | 35 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 70 deletions(-) create mode 100644 src/infra/gateway-process-argv.ts diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index cd54df8035a78..7fa7396d0b0eb 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -5,6 +5,7 @@ import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js import { parseCmdScriptCommandLine } from "../../daemon/cmd-argv.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { probeGateway } from "../../gateway/probe.js"; +import { isGatewayArgv, parseProcCmdline } from "../../infra/gateway-process-argv.js"; import { findGatewayPidsOnPortSync } from "../../infra/restart.js"; import { defaultRuntime } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; @@ -42,17 +43,6 @@ async function resolveGatewayLifecyclePort(service = resolveGatewayService()) { return portFromArgs ?? resolveGatewayPort(await readBestEffortConfig(), mergedEnv); } -function normalizeProcArg(arg: string): string { - return arg.replaceAll("\\", "/").toLowerCase(); -} - -function parseProcCmdline(raw: string): string[] { - return raw - .split("\0") - .map((entry) => entry.trim()) - .filter(Boolean); -} - function extractWindowsCommandLine(raw: string): string | null { const lines = raw .split(/\r?\n/) @@ -68,31 +58,6 @@ function extractWindowsCommandLine(raw: string): string | null { return lines.find((line) => line.toLowerCase() !== "commandline") ?? null; } -function stripExecutableExtension(value: string): string { - return value.replace(/\.(bat|cmd|exe)$/i, ""); -} - -function isGatewayArgv(args: string[]): boolean { - const normalized = args.map(normalizeProcArg); - if (!normalized.includes("gateway")) { - return false; - } - - const entryCandidates = [ - "dist/index.js", - "dist/entry.js", - "openclaw.mjs", - "scripts/run-node.mjs", - "src/index.ts", - ]; - if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { - return true; - } - - const exe = stripExecutableExtension(normalized[0] ?? ""); - return exe.endsWith("/openclaw") || exe === "openclaw" || exe.endsWith("/openclaw-gateway"); -} - function readGatewayProcessArgsSync(pid: number): string[] | null { if (process.platform === "linux") { try { @@ -135,7 +100,7 @@ function resolveGatewayListenerPids(port: number): number[] { .filter((pid): pid is number => Number.isFinite(pid) && pid > 0) .filter((pid) => { const args = readGatewayProcessArgsSync(pid); - return args != null && isGatewayArgv(args); + return args != null && isGatewayArgv(args, { allowGatewayBinary: true }); }); } @@ -147,7 +112,7 @@ function resolveGatewayPortFallback(): Promise { function signalGatewayPid(pid: number, signal: "SIGTERM" | "SIGUSR1") { const args = readGatewayProcessArgsSync(pid); - if (!args || !isGatewayArgv(args)) { + if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) { throw new Error(`refusing to signal non-gateway process pid ${pid}`); } process.kill(pid, signal); diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts index 6e6b71cf2d1da..502e06dec3a29 100644 --- a/src/infra/gateway-lock.ts +++ b/src/infra/gateway-lock.ts @@ -5,6 +5,7 @@ import net from "node:net"; import path from "node:path"; import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { isPidAlive } from "../shared/pid-alive.js"; +import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; const DEFAULT_TIMEOUT_MS = 5000; const DEFAULT_POLL_INTERVAL_MS = 100; @@ -46,38 +47,6 @@ export class GatewayLockError extends Error { type LockOwnerStatus = "alive" | "dead" | "unknown"; -function normalizeProcArg(arg: string): string { - return arg.replaceAll("\\", "/").toLowerCase(); -} - -function parseProcCmdline(raw: string): string[] { - return raw - .split("\0") - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function isGatewayArgv(args: string[]): boolean { - const normalized = args.map(normalizeProcArg); - if (!normalized.includes("gateway")) { - return false; - } - - const entryCandidates = [ - "dist/index.js", - "dist/entry.js", - "openclaw.mjs", - "scripts/run-node.mjs", - "src/index.ts", - ]; - if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { - return true; - } - - const exe = normalized[0] ?? ""; - return exe.endsWith("/openclaw") || exe === "openclaw"; -} - function readLinuxCmdline(pid: number): string[] | null { try { const raw = fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8"); diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts new file mode 100644 index 0000000000000..59f042ead8832 --- /dev/null +++ b/src/infra/gateway-process-argv.ts @@ -0,0 +1,35 @@ +function normalizeProcArg(arg: string): string { + return arg.replaceAll("\\", "/").toLowerCase(); +} + +export function parseProcCmdline(raw: string): string[] { + return raw + .split("\0") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean { + const normalized = args.map(normalizeProcArg); + if (!normalized.includes("gateway")) { + return false; + } + + const entryCandidates = [ + "dist/index.js", + "dist/entry.js", + "openclaw.mjs", + "scripts/run-node.mjs", + "src/index.ts", + ]; + if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) { + return true; + } + + const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, ""); + return ( + exe.endsWith("/openclaw") || + exe === "openclaw" || + (opts?.allowGatewayBinary === true && exe.endsWith("/openclaw-gateway")) + ); +} From 3f2f007c9af16da6027915d0b1872821ea8119bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:41:40 +0000 Subject: [PATCH 182/311] refactor: extract gateway port diagnostics helper --- src/cli/daemon-cli/restart-health.ts | 38 +++++++++++++--------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/cli/daemon-cli/restart-health.ts b/src/cli/daemon-cli/restart-health.ts index 00b6b4e98b3f1..13741d2e9c41c 100644 --- a/src/cli/daemon-cli/restart-health.ts +++ b/src/cli/daemon-cli/restart-health.ts @@ -242,20 +242,8 @@ export async function waitForGatewayHealthyListener(params: { return snapshot; } -export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): string[] { +function renderPortUsageDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] { const lines: string[] = []; - const runtimeSummary = [ - snapshot.runtime.status ? `status=${snapshot.runtime.status}` : null, - snapshot.runtime.state ? `state=${snapshot.runtime.state}` : null, - snapshot.runtime.pid != null ? `pid=${snapshot.runtime.pid}` : null, - snapshot.runtime.lastExitStatus != null ? `lastExit=${snapshot.runtime.lastExitStatus}` : null, - ] - .filter(Boolean) - .join(", "); - - if (runtimeSummary) { - lines.push(`Service runtime: ${runtimeSummary}`); - } if (snapshot.portUsage.status === "busy") { lines.push(...formatPortDiagnostics(snapshot.portUsage)); @@ -270,22 +258,30 @@ export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): stri return lines; } -export function renderGatewayPortHealthDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] { +export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): string[] { const lines: string[] = []; + const runtimeSummary = [ + snapshot.runtime.status ? `status=${snapshot.runtime.status}` : null, + snapshot.runtime.state ? `state=${snapshot.runtime.state}` : null, + snapshot.runtime.pid != null ? `pid=${snapshot.runtime.pid}` : null, + snapshot.runtime.lastExitStatus != null ? `lastExit=${snapshot.runtime.lastExitStatus}` : null, + ] + .filter(Boolean) + .join(", "); - if (snapshot.portUsage.status === "busy") { - lines.push(...formatPortDiagnostics(snapshot.portUsage)); - } else { - lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`); + if (runtimeSummary) { + lines.push(`Service runtime: ${runtimeSummary}`); } - if (snapshot.portUsage.errors?.length) { - lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`); - } + lines.push(...renderPortUsageDiagnostics(snapshot)); return lines; } +export function renderGatewayPortHealthDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] { + return renderPortUsageDiagnostics(snapshot); +} + export async function terminateStaleGatewayPids(pids: number[]): Promise { const targets = Array.from( new Set(pids.filter((pid): pid is number => Number.isFinite(pid) && pid > 0)), From 52a253f18c93bdad81a892db2508e965b8afd8cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:42:15 +0000 Subject: [PATCH 183/311] refactor: reuse broadcast route key construction --- src/web/auto-reply/monitor/broadcast.ts | 73 ++++++++++++++----------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts index d220c9a829c88..1dc51bef17959 100644 --- a/src/web/auto-reply/monitor/broadcast.ts +++ b/src/web/auto-reply/monitor/broadcast.ts @@ -11,6 +11,39 @@ import { whatsappInboundLog } from "../loggers.js"; import type { WebInboundMsg } from "../types.js"; import type { GroupHistoryEntry } from "./process-message.js"; +function buildBroadcastRouteKeys(params: { + cfg: ReturnType; + msg: WebInboundMsg; + route: ReturnType; + peerId: string; + agentId: string; +}) { + const sessionKey = buildAgentSessionKey({ + agentId: params.agentId, + channel: "whatsapp", + accountId: params.route.accountId, + peer: { + kind: params.msg.chatType === "group" ? "group" : "direct", + id: params.peerId, + }, + dmScope: params.cfg.session?.dmScope, + identityLinks: params.cfg.session?.identityLinks, + }); + const mainSessionKey = buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: DEFAULT_MAIN_KEY, + }); + + return { + sessionKey, + mainSessionKey, + lastRoutePolicy: deriveLastRoutePolicy({ + sessionKey, + mainSessionKey, + }), + }; +} + export async function maybeBroadcastMessage(params: { cfg: ReturnType; msg: WebInboundMsg; @@ -52,41 +85,17 @@ export async function maybeBroadcastMessage(params: { whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`); return false; } + const routeKeys = buildBroadcastRouteKeys({ + cfg: params.cfg, + msg: params.msg, + route: params.route, + peerId: params.peerId, + agentId: normalizedAgentId, + }); const agentRoute = { ...params.route, agentId: normalizedAgentId, - sessionKey: buildAgentSessionKey({ - agentId: normalizedAgentId, - channel: "whatsapp", - accountId: params.route.accountId, - peer: { - kind: params.msg.chatType === "group" ? "group" : "direct", - id: params.peerId, - }, - dmScope: params.cfg.session?.dmScope, - identityLinks: params.cfg.session?.identityLinks, - }), - mainSessionKey: buildAgentMainSessionKey({ - agentId: normalizedAgentId, - mainKey: DEFAULT_MAIN_KEY, - }), - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: buildAgentSessionKey({ - agentId: normalizedAgentId, - channel: "whatsapp", - accountId: params.route.accountId, - peer: { - kind: params.msg.chatType === "group" ? "group" : "direct", - id: params.peerId, - }, - dmScope: params.cfg.session?.dmScope, - identityLinks: params.cfg.session?.identityLinks, - }), - mainSessionKey: buildAgentMainSessionKey({ - agentId: normalizedAgentId, - mainKey: DEFAULT_MAIN_KEY, - }), - }), + ...routeKeys, }; try { From 5845b5bfbac7db46e30df291fec5b830f0b036fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:43:18 +0000 Subject: [PATCH 184/311] refactor: share multi-account config schema fragments --- extensions/bluebubbles/src/config-schema.ts | 13 ++++++------- extensions/zalo/src/config-schema.ts | 12 ++++-------- extensions/zalouser/src/config-schema.ts | 12 ++++-------- src/channels/plugins/config-schema.ts | 17 ++++++++++++++++- src/plugin-sdk/index.ts | 4 ++++ 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index bc4ec0e3f671f..94a0661afb764 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,9 +1,8 @@ +import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles"; import { z } from "zod"; import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; -const allowFromEntry = z.union([z.string(), z.number()]); - const bluebubblesActionSchema = z .object({ reactions: z.boolean().default(true), @@ -34,8 +33,8 @@ const bluebubblesAccountSchema = z password: buildSecretInputSchema().optional(), webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), + allowFrom: z.array(AllowFromEntrySchema).optional(), + groupAllowFrom: z.array(AllowFromEntrySchema).optional(), groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), @@ -60,8 +59,8 @@ const bluebubblesAccountSchema = z } }); -export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ - accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(), - defaultAccount: z.string().optional(), +export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema( + bluebubblesAccountSchema, +).extend({ actions: bluebubblesActionSchema, }); diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 7f2c0f360bac6..f2e5c5803e76f 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,9 +1,8 @@ +import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo"; import { z } from "zod"; import { buildSecretInputSchema } from "./secret-input.js"; -const allowFromEntry = z.union([z.string(), z.number()]); - const zaloAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), @@ -14,15 +13,12 @@ const zaloAccountSchema = z.object({ webhookSecret: buildSecretInputSchema().optional(), webhookPath: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), + allowFrom: z.array(AllowFromEntrySchema).optional(), groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(AllowFromEntrySchema).optional(), mediaMaxMb: z.number().optional(), proxy: z.string().optional(), responsePrefix: z.string().optional(), }); -export const ZaloConfigSchema = zaloAccountSchema.extend({ - accounts: z.object({}).catchall(zaloAccountSchema).optional(), - defaultAccount: z.string().optional(), -}); +export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema); diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 9d6b0bcec4ab6..dd0f9c51fbe18 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,8 +1,7 @@ +import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser"; import { z } from "zod"; -const allowFromEntry = z.union([z.string(), z.number()]); - const groupConfigSchema = z.object({ allow: z.boolean().optional(), enabled: z.boolean().optional(), @@ -16,16 +15,13 @@ const zalouserAccountSchema = z.object({ markdown: MarkdownConfigSchema, profile: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), + allowFrom: z.array(AllowFromEntrySchema).optional(), historyLimit: z.number().int().min(0).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(AllowFromEntrySchema).optional(), groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(), groups: z.object({}).catchall(groupConfigSchema).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), }); -export const ZalouserConfigSchema = zalouserAccountSchema.extend({ - accounts: z.object({}).catchall(zalouserAccountSchema).optional(), - defaultAccount: z.string().optional(), -}); +export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema); diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 75074ae569d9a..35be4c9d388e1 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,10 +1,25 @@ -import type { ZodTypeAny } from "zod"; +import { z, type ZodTypeAny } from "zod"; import type { ChannelConfigSchema } from "./types.plugin.js"; type ZodSchemaWithToJsonSchema = ZodTypeAny & { toJSONSchema?: (params?: Record) => unknown; }; +type ExtendableZodObject = ZodTypeAny & { + extend: (shape: Record) => ZodTypeAny; +}; + +export const AllowFromEntrySchema = z.union([z.string(), z.number()]); + +export function buildCatchallMultiAccountChannelSchema( + accountSchema: T, +): T { + return accountSchema.extend({ + accounts: z.object({}).catchall(accountSchema).optional(), + defaultAccount: z.string().optional(), + }) as T; +} + export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { const schemaWithJson = schema as ZodSchemaWithToJsonSchema; if (typeof schemaWithJson.toJSONSchema === "function") { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 816d644cd991e..89340787e923b 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -195,6 +195,10 @@ export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { ChannelSendRawResult } from "./channel-send-result.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; +export { + AllowFromEntrySchema, + buildCatchallMultiAccountChannelSchema, +} from "../channels/plugins/config-schema.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; From bf601db3fc5f6c0de404164689bf9d9e732d2e2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:43:54 +0000 Subject: [PATCH 185/311] test: dedupe brave llm-context rejection cases --- .../tools/web-tools.enabled-defaults.test.ts | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index e9da69080d2e1..54485908b8b22 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -772,40 +772,25 @@ describe("web_search external content wrapping", () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it("rejects date_after/date_before in Brave llm-context mode", async () => { - vi.stubEnv("BRAVE_API_KEY", "test-key"); - const mockFetch = installBraveLlmContextFetch({ - title: "unused", - url: "https://example.com", - snippets: ["unused"], - }); - - const tool = createWebSearchTool({ - config: { - tools: { - web: { - search: { - provider: "brave", - brave: { - mode: "llm-context", - }, - }, - }, - }, + it.each([ + [ + "rejects date_after/date_before in Brave llm-context mode", + { + query: "test", + date_after: "2025-01-01", + date_before: "2025-01-31", }, - sandboxed: true, - }); - const result = await tool?.execute?.("call-1", { - query: "test", - date_after: "2025-01-01", - date_before: "2025-01-31", - }); - - expect(result?.details).toMatchObject({ error: "unsupported_date_filter" }); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("rejects ui_lang in Brave llm-context mode", async () => { + "unsupported_date_filter", + ], + [ + "rejects ui_lang in Brave llm-context mode", + { + query: "test", + ui_lang: "de-DE", + }, + "unsupported_ui_lang", + ], + ])("%s", async (_name, input, expectedError) => { vi.stubEnv("BRAVE_API_KEY", "test-key"); const mockFetch = installBraveLlmContextFetch({ title: "unused", @@ -828,12 +813,9 @@ describe("web_search external content wrapping", () => { }, sandboxed: true, }); - const result = await tool?.execute?.("call-1", { - query: "test", - ui_lang: "de-DE", - }); + const result = await tool?.execute?.("call-1", input); - expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" }); + expect(result?.details).toMatchObject({ error: expectedError }); expect(mockFetch).not.toHaveBeenCalled(); }); From 936ac22ec222be0f60de22fc63884caa7fb6698d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 19:46:20 +0000 Subject: [PATCH 186/311] refactor: share channel config adapter base --- extensions/discord/src/channel.ts | 32 ++++++---------- extensions/googlechat/src/channel.ts | 48 +++++++++-------------- extensions/slack/src/channel.ts | 32 ++++++---------- extensions/telegram/src/channel.ts | 32 ++++++---------- src/plugin-sdk/channel-config-helpers.ts | 49 ++++++++++++++++++++++++ src/plugin-sdk/index.ts | 1 + 6 files changed, 102 insertions(+), 92 deletions(-) diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index cd3483bce00ba..23a4a2ffae8c9 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, @@ -13,7 +14,6 @@ import { collectDiscordAuditChannelIds, collectDiscordStatusIssues, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, discordOnboardingAdapter, DiscordConfigSchema, getChatChannelMeta, @@ -33,7 +33,6 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, - setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, type ResolvedDiscordAccount, @@ -63,6 +62,15 @@ const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, }); +const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: "discord", + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + export const discordPlugin: ChannelPlugin = { id: "discord", meta: { @@ -93,25 +101,7 @@ export const discordPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.discord"] }, configSchema: buildChannelConfigSchema(DiscordConfigSchema), config: { - listAccountIds: (cfg) => listDiscordAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "discord", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "discord", - accountId, - clearBaseFields: ["token", "name"], - }), + ...discordConfigBase, isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 775145f5d540d..f0c5dace9f04f 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { buildAccountScopedDmSecurityPolicy, buildOpenGroupPolicyConfigureRouteAllowlistWarning, @@ -11,7 +12,6 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, listDirectoryGroupEntriesFromMapKeys, listDirectoryUserEntriesFromAllowFrom, @@ -21,7 +21,6 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, - setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -68,6 +67,23 @@ const googleChatConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo, }); +const googleChatConfigBase = createScopedChannelConfigBase({ + sectionKey: "googlechat", + listAccountIds: listGoogleChatAccountIds, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultGoogleChatAccountId, + clearBaseFields: [ + "serviceAccount", + "serviceAccountFile", + "audienceType", + "audience", + "webhookPath", + "webhookUrl", + "botUser", + "name", + ], +}); + export const googlechatDock: ChannelDock = { id: "googlechat", capabilities: { @@ -142,33 +158,7 @@ export const googlechatPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.googlechat"] }, configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), config: { - listAccountIds: (cfg) => listGoogleChatAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg, - sectionKey: "googlechat", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg, - sectionKey: "googlechat", - accountId, - clearBaseFields: [ - "serviceAccount", - "serviceAccountFile", - "audienceType", - "audience", - "webhookPath", - "webhookUrl", - "botUser", - "name", - ], - }), + ...googleChatConfigBase, isConfigured: (account) => account.credentialSource !== "none", describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 98010e907f43d..1fdf4018f2823 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, @@ -10,7 +11,6 @@ import { buildComputedAccountStatusSnapshot, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, extractSlackToolSend, getChatChannelMeta, handleSlackMessageAction, @@ -32,7 +32,6 @@ import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, - setAccountEnabledInConfigSection, slackOnboardingAdapter, SlackConfigSchema, type ChannelPlugin, @@ -96,6 +95,15 @@ const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, }); +const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: "slack", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + export const slackPlugin: ChannelPlugin = { id: "slack", meta: { @@ -144,25 +152,7 @@ export const slackPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.slack"] }, configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { - listAccountIds: (cfg) => listSlackAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "slack", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "slack", - accountId, - clearBaseFields: ["botToken", "appToken", "name"], - }), + ...slackConfigBase, isConfigured: (account) => isSlackAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 2cd2bf8ff51d0..d8879ab5858c1 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,3 +1,4 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; import { collectAllowlistProviderGroupPolicyWarnings, buildAccountScopedDmSecurityPolicy, @@ -12,7 +13,6 @@ import { clearAccountEntryFields, collectTelegramStatusIssues, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, getChatChannelMeta, inspectTelegramAccount, listTelegramAccountIds, @@ -31,7 +31,6 @@ import { resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - setAccountEnabledInConfigSection, telegramOnboardingAdapter, TelegramConfigSchema, type ChannelMessageActionAdapter, @@ -100,6 +99,15 @@ const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, }); +const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { @@ -136,25 +144,7 @@ export const telegramPlugin: ChannelPlugin listTelegramAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "telegram", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "telegram", - accountId, - clearBaseFields: ["botToken", "tokenFile", "name"], - }), + ...telegramConfigBase, isConfigured: (account, cfg) => { if (!account.token?.trim()) { return false; diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index 754e2a57c1abf..afcd312f1c88f 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -1,3 +1,7 @@ +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js"; import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -55,6 +59,51 @@ export function createScopedAccountConfigAccessors(params: { }; } +export function createScopedChannelConfigBase< + ResolvedAccount, + Config extends OpenClawConfig = OpenClawConfig, +>(params: { + sectionKey: string; + listAccountIds: (cfg: Config) => string[]; + resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount; + defaultAccountId: (cfg: Config) => string; + inspectAccount?: (cfg: Config, accountId?: string | null) => unknown; + clearBaseFields: string[]; + allowTopLevel?: boolean; +}): Pick< + ChannelConfigAdapter, + | "listAccountIds" + | "resolveAccount" + | "inspectAccount" + | "defaultAccountId" + | "setAccountEnabled" + | "deleteAccount" +> { + return { + listAccountIds: (cfg) => params.listAccountIds(cfg as Config), + resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId), + inspectAccount: params.inspectAccount + ? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId) + : undefined, + defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + enabled, + allowTopLevel: params.allowTopLevel ?? true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as Config, + sectionKey: params.sectionKey, + accountId, + clearBaseFields: params.clearBaseFields, + }), + }; +} + export function resolveWhatsAppConfigAllowFrom(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 89340787e923b..3e1ba0f03ab8e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -195,6 +195,7 @@ export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { ChannelSendRawResult } from "./channel-send-result.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; +export { createScopedChannelConfigBase } from "./channel-config-helpers.js"; export { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema, From 661af2acd3948d4a0e39c65bc9a8115bb8ecf01e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 23:38:14 +0000 Subject: [PATCH 187/311] fix(agents): bootstrap runtime plugins before context-engine resolution --- CHANGELOG.md | 2 + .../pi-embedded-runner/compact.hooks.test.ts | 20 ++++ src/agents/pi-embedded-runner/compact.ts | 9 ++ src/agents/pi-embedded-runner/run.ts | 5 + .../usage-reporting.test.ts | 35 +++++++ src/agents/runtime-plugins.ts | 18 ++++ .../subagent-registry.context-engine.test.ts | 91 +++++++++++++++++++ src/agents/subagent-registry.ts | 15 ++- src/agents/subagent-registry.types.ts | 1 + src/agents/subagent-spawn.ts | 1 + 10 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/agents/runtime-plugins.ts create mode 100644 src/agents/subagent-registry.context-engine.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74953047f5344..12a1a9063b232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. +<<<<<<< HEAD - Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. @@ -44,6 +45,7 @@ Docs: https://docs.openclaw.ai - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. - Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. - TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. +- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232) ## 2026.3.7 diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index c6eb54b0501a7..9ef2a3efe76b4 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { hookRunner, + ensureRuntimePluginsLoaded, resolveModelMock, sessionCompactImpl, triggerInternalHook, @@ -12,6 +13,7 @@ const { runBeforeCompaction: vi.fn(), runAfterCompaction: vi.fn(), }, + ensureRuntimePluginsLoaded: vi.fn(), resolveModelMock: vi.fn(() => ({ model: { provider: "openai", api: "responses", id: "fake", input: [] }, error: null, @@ -32,6 +34,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookRunner, })); +vi.mock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded, +})); + vi.mock("../../hooks/internal-hooks.js", async () => { const actual = await vi.importActual( "../../hooks/internal-hooks.js", @@ -254,6 +260,7 @@ const sessionHook = (action: string) => describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { + ensureRuntimePluginsLoaded.mockReset(); triggerInternalHook.mockClear(); hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); @@ -279,6 +286,19 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); + it("bootstraps runtime plugins with the resolved workspace", async () => { + await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + }); + + expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: undefined, + workspaceDir: "/tmp/workspace", + }); + }); + it("emits internal + plugin compaction hooks with counts", async () => { hookRunner.hasHooks.mockReturnValue(true); let sanitizedCount = 0; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ad5cecd8bd2ed..91f99571db4c2 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -50,6 +50,7 @@ import { } from "../pi-embedded-helpers.js"; import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js"; import { createOpenClawCodingTools } from "../pi-tools.js"; +import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; @@ -269,6 +270,10 @@ export async function compactEmbeddedPiSessionDirect( const maxAttempts = params.maxAttempts ?? 1; const runId = params.runId ?? params.sessionId; const resolvedWorkspace = resolveUserPath(params.workspaceDir); + ensureRuntimePluginsLoaded({ + config: params.config, + workspaceDir: resolvedWorkspace, + }); const prevCwd = process.cwd(); // Resolve compaction model: prefer config override, then fall back to caller-supplied model @@ -910,6 +915,10 @@ export async function compactEmbeddedPiSession( params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); return enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { + ensureRuntimePluginsLoaded({ + config: params.config, + workspaceDir: params.workspaceDir, + }); ensureContextEnginesInitialized(); const contextEngine = await resolveContextEngine(params.config); try { diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index c96089a9f55a4..21b29fe2cb6fb 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -54,6 +54,7 @@ import { pickFallbackThinkingLevel, type FailoverReason, } from "../pi-embedded-helpers.js"; +import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; @@ -287,6 +288,10 @@ export async function runEmbeddedPiAgent( `[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, ); } + ensureRuntimePluginsLoaded({ + config: params.config, + workspaceDir: resolvedWorkspace, + }); const prevCwd = process.cwd(); let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index f4d6f5cbe445b..48cb586e7275f 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -1,5 +1,14 @@ import "./run.overflow-compaction.mocks.shared.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runtimePluginMocks = vi.hoisted(() => ({ + ensureRuntimePluginsLoaded: vi.fn(), +})); + +vi.mock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, +})); + import { runEmbeddedPiAgent } from "./run.js"; import { runEmbeddedAttempt } from "./run/attempt.js"; @@ -10,6 +19,32 @@ describe("runEmbeddedPiAgent usage reporting", () => { vi.clearAllMocks(); }); + it("bootstraps runtime plugins with the resolved workspace before running", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce({ + aborted: false, + promptError: null, + timedOut: false, + sessionIdUsed: "test-session", + assistantTexts: ["Response 1"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + await runEmbeddedPiAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-plugin-bootstrap", + }); + + expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: undefined, + workspaceDir: "/tmp/workspace", + }); + }); + it("forwards sender identity fields into embedded attempts", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce({ aborted: false, diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts new file mode 100644 index 0000000000000..ace53258e0f8d --- /dev/null +++ b/src/agents/runtime-plugins.ts @@ -0,0 +1,18 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { resolveUserPath } from "../utils.js"; + +export function ensureRuntimePluginsLoaded(params: { + config?: OpenClawConfig; + workspaceDir?: string | null; +}): void { + const workspaceDir = + typeof params.workspaceDir === "string" && params.workspaceDir.trim() + ? resolveUserPath(params.workspaceDir) + : undefined; + + loadOpenClawPlugins({ + config: params.config, + workspaceDir, + }); +} diff --git a/src/agents/subagent-registry.context-engine.test.ts b/src/agents/subagent-registry.context-engine.test.ts new file mode 100644 index 0000000000000..59eea1bd4c703 --- /dev/null +++ b/src/agents/subagent-registry.context-engine.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensureRuntimePluginsLoaded: vi.fn(), + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(), + onSubagentEnded: vi.fn(async () => {}), + onAgentEvent: vi.fn(() => () => {}), + persistSubagentRunsToDisk: vi.fn(), +})); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: vi.fn(() => ({})), + }; +}); + +vi.mock("../context-engine/init.js", () => ({ + ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized, +})); + +vi.mock("../context-engine/registry.js", () => ({ + resolveContextEngine: mocks.resolveContextEngine, +})); + +vi.mock("../infra/agent-events.js", () => ({ + onAgentEvent: mocks.onAgentEvent, +})); + +vi.mock("./runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded, +})); + +vi.mock("./subagent-registry-state.js", () => ({ + getSubagentRunsSnapshotForRead: vi.fn((runs: Map) => new Map(runs)), + persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk, + restoreSubagentRunsFromDisk: vi.fn(() => 0), +})); + +vi.mock("./subagent-announce-queue.js", () => ({ + resetAnnounceQueuesForTests: vi.fn(), +})); + +vi.mock("./timeout.js", () => ({ + resolveAgentTimeoutMs: vi.fn(() => 1_000), +})); + +import { + registerSubagentRun, + releaseSubagentRun, + resetSubagentRegistryForTests, +} from "./subagent-registry.js"; + +describe("subagent-registry context-engine bootstrap", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveContextEngine.mockResolvedValue({ + onSubagentEnded: mocks.onSubagentEnded, + }); + resetSubagentRegistryForTests({ persist: false }); + }); + + it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => { + registerSubagentRun({ + runId: "run-1", + childSessionKey: "agent:main:session:child", + requesterSessionKey: "agent:main:session:parent", + requesterDisplayKey: "parent", + task: "task", + cleanup: "keep", + workspaceDir: "/tmp/workspace", + }); + + releaseSubagentRun("run-1"); + + await vi.waitFor(() => { + expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: {}, + workspaceDir: "/tmp/workspace", + }); + }); + expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1); + expect(mocks.onSubagentEnded).toHaveBeenCalledWith({ + childSessionKey: "agent:main:session:child", + reason: "released", + workspaceDir: "/tmp/workspace", + }); + }); +}); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index e2453bcc0fd12..9ef58933f35a7 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -16,6 +16,7 @@ import { onAgentEvent } from "../infra/agent-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { defaultRuntime } from "../runtime.js"; import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js"; import { captureSubagentCompletionReply, @@ -313,10 +314,16 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number; async function notifyContextEngineSubagentEnded(params: { childSessionKey: string; reason: SubagentEndReason; + workspaceDir?: string; }) { try { + const cfg = loadConfig(); + ensureRuntimePluginsLoaded({ + config: cfg, + workspaceDir: params.workspaceDir, + }); ensureContextEnginesInitialized(); - const engine = await resolveContextEngine(loadConfig()); + const engine = await resolveContextEngine(cfg); if (!engine.onSubagentEnded) { return; } @@ -714,6 +721,7 @@ async function sweepSubagentRuns() { void notifyContextEngineSubagentEnded({ childSessionKey: entry.childSessionKey, reason: "swept", + workspaceDir: entry.workspaceDir, }); subagentRuns.delete(runId); mutated = true; @@ -963,6 +971,7 @@ function completeCleanupBookkeeping(params: { void notifyContextEngineSubagentEnded({ childSessionKey: params.entry.childSessionKey, reason: "deleted", + workspaceDir: params.entry.workspaceDir, }); subagentRuns.delete(params.runId); persistSubagentRuns(); @@ -972,6 +981,7 @@ function completeCleanupBookkeeping(params: { void notifyContextEngineSubagentEnded({ childSessionKey: params.entry.childSessionKey, reason: "completed", + workspaceDir: params.entry.workspaceDir, }); params.entry.cleanupCompletedAt = params.completedAt; persistSubagentRuns(); @@ -1143,6 +1153,7 @@ export function registerSubagentRun(params: { cleanup: "delete" | "keep"; label?: string; model?: string; + workspaceDir?: string; runTimeoutSeconds?: number; expectsCompletionMessage?: boolean; spawnMode?: "run" | "session"; @@ -1171,6 +1182,7 @@ export function registerSubagentRun(params: { spawnMode, label: params.label, model: params.model, + workspaceDir: params.workspaceDir, runTimeoutSeconds, createdAt: now, startedAt: now, @@ -1285,6 +1297,7 @@ export function releaseSubagentRun(runId: string) { void notifyContextEngineSubagentEnded({ childSessionKey: entry.childSessionKey, reason: "released", + workspaceDir: entry.workspaceDir, }); } const didDelete = subagentRuns.delete(runId); diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index a97ed7807237f..a153ddbadd778 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -13,6 +13,7 @@ export type SubagentRunRecord = { cleanup: "delete" | "keep"; label?: string; model?: string; + workspaceDir?: string; runTimeoutSeconds?: number; spawnMode?: SpawnSubagentMode; createdAt: number; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 8f7c41866fee0..f2a63552189ad 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -650,6 +650,7 @@ export async function spawnSubagentDirect( cleanup, label: label || undefined, model: resolvedModel, + workspaceDir: spawnedMetadata.workspaceDir, runTimeoutSeconds, expectsCompletionMessage, spawnMode, From d47aa6bae89870ad7267f5ebc94ebf977443a0a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 23:39:03 +0000 Subject: [PATCH 188/311] docs(changelog): remove rebase marker --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a1a9063b232..e68bb2b046962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,6 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. -<<<<<<< HEAD - Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline. - Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. - Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. From 362248e55908296da1521ad3c4572fb4803d465b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 23:45:59 +0000 Subject: [PATCH 189/311] refactor: harden browser relay CDP flows --- assets/chrome-extension/background-utils.js | 16 +++ assets/chrome-extension/background.js | 70 +++++++--- src/agents/tools/browser-tool.actions.ts | 71 +++++----- src/agents/tools/browser-tool.test.ts | 50 ++++++- src/agents/tools/browser-tool.ts | 6 +- .../chrome-extension-background-utils.test.ts | 27 +++- src/browser/client.test.ts | 15 +++ src/browser/client.ts | 6 +- ...ge-for-targetid.extension-fallback.test.ts | 63 +++++++++ src/browser/pw-session.page-cdp.test.ts | 94 +++++++++++++ src/browser/pw-session.page-cdp.ts | 81 +++++++++++ src/browser/pw-session.ts | 111 +++++++++------ src/browser/pw-tools-core.snapshot.ts | 27 ++-- src/browser/pw-tools-core.state.ts | 110 ++++++++------- src/browser/server-lifecycle.test.ts | 59 ++++++++ src/browser/server-lifecycle.ts | 18 ++- src/node-host/invoke-browser.test.ts | 99 ++++++++++++++ src/node-host/invoke-browser.ts | 127 ++++++++++++++++-- 18 files changed, 874 insertions(+), 176 deletions(-) create mode 100644 src/browser/pw-session.page-cdp.test.ts create mode 100644 src/browser/pw-session.page-cdp.ts create mode 100644 src/node-host/invoke-browser.test.ts diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js index fe32d2c061656..82d43359c0ada 100644 --- a/assets/chrome-extension/background-utils.js +++ b/assets/chrome-extension/background-utils.js @@ -46,3 +46,19 @@ export function isRetryableReconnectError(err) { } return true; } + +export function isMissingTabError(err) { + const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase(); + return ( + message.includes("no tab with id") || + message.includes("no tab with given id") || + message.includes("tab not found") + ); +} + +export function isLastRemainingTab(allTabs, tabIdToClose) { + if (!Array.isArray(allTabs)) { + return true; + } + return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0; +} diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 0c4252f3a854c..9031a15648959 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,4 +1,10 @@ -import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +import { + buildRelayWsUrl, + isLastRemainingTab, + isMissingTabError, + isRetryableReconnectError, + reconnectDelayMs, +} from './background-utils.js' const DEFAULT_PORT = 18792 @@ -41,6 +47,9 @@ const reattachPending = new Set() let reconnectAttempt = 0 let reconnectTimer = null +const TAB_VALIDATION_ATTEMPTS = 2 +const TAB_VALIDATION_RETRY_DELAY_MS = 1000 + function nowStack() { try { return new Error().stack || '' @@ -49,6 +58,37 @@ function nowStack() { } } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function validateAttachedTab(tabId) { + try { + await chrome.tabs.get(tabId) + } catch { + return false + } + + for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) { + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + return true + } catch (err) { + if (isMissingTabError(err)) { + return false + } + if (attempt < TAB_VALIDATION_ATTEMPTS - 1) { + await sleep(TAB_VALIDATION_RETRY_DELAY_MS) + } + } + } + + return false +} + async function getRelayPort() { const stored = await chrome.storage.local.get(['relayPort']) const raw = stored.relayPort @@ -108,15 +148,11 @@ async function rehydrateState() { tabBySession.set(entry.sessionId, entry.tabId) setBadge(entry.tabId, 'on') } - // Phase 2: validate asynchronously, remove dead tabs. + // Retry once so transient busy/navigation states do not permanently drop + // a still-attached tab after a service worker restart. for (const entry of entries) { - try { - await chrome.tabs.get(entry.tabId) - await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { - expression: '1', - returnByValue: true, - }) - } catch { + const valid = await validateAttachedTab(entry.tabId) + if (!valid) { tabs.delete(entry.tabId) tabBySession.delete(entry.sessionId) setBadge(entry.tabId, 'off') @@ -259,13 +295,10 @@ async function reannounceAttachedTabs() { for (const [tabId, tab] of tabs.entries()) { if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue - // Verify debugger is still attached. - try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { - expression: '1', - returnByValue: true, - }) - } catch { + // Retry once here as well; reconnect races can briefly make an otherwise + // healthy tab look unavailable. + const valid = await validateAttachedTab(tabId) + if (!valid) { tabs.delete(tabId) if (tab.sessionId) tabBySession.delete(tab.sessionId) setBadge(tabId, 'off') @@ -672,6 +705,11 @@ async function handleForwardCdpCommand(msg) { const toClose = target ? getTabByTargetId(target) : tabId if (!toClose) return { success: false } try { + const allTabs = await chrome.tabs.query({}) + if (isLastRemainingTab(allTabs, toClose)) { + console.warn('Refusing to close the last tab: this would kill the browser process') + return { success: false, error: 'Cannot close the last tab' } + } await chrome.tabs.remove(toClose) } catch { return { success: false } diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 6c156e0cf2dd9..673585d16b373 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -112,16 +112,19 @@ export async function executeSnapshotAction(params: { }): Promise> { const { input, baseUrl, profile, proxyRequest } = params; const snapshotDefaults = loadConfig().browser?.snapshotDefaults; - const format = - input.snapshotFormat === "ai" || input.snapshotFormat === "aria" ? input.snapshotFormat : "ai"; - const mode = + const format: "ai" | "aria" | undefined = + input.snapshotFormat === "ai" || input.snapshotFormat === "aria" + ? input.snapshotFormat + : undefined; + const mode: "efficient" | undefined = input.mode === "efficient" ? "efficient" - : format === "ai" && snapshotDefaults?.mode === "efficient" + : format !== "aria" && snapshotDefaults?.mode === "efficient" ? "efficient" : undefined; const labels = typeof input.labels === "boolean" ? input.labels : undefined; - const refs = input.refs === "aria" || input.refs === "role" ? input.refs : undefined; + const refs: "aria" | "role" | undefined = + input.refs === "aria" || input.refs === "role" ? input.refs : undefined; const hasMaxChars = Object.hasOwn(input, "maxChars"); const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined; const limit = @@ -130,6 +133,12 @@ export async function executeSnapshotAction(params: { typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0 ? Math.floor(input.maxChars) : undefined; + const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined; + const compact = typeof input.compact === "boolean" ? input.compact : undefined; + const depth = + typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined; + const selector = typeof input.selector === "string" ? input.selector.trim() : undefined; + const frame = typeof input.frame === "string" ? input.frame.trim() : undefined; const resolvedMaxChars = format === "ai" ? hasMaxChars @@ -137,46 +146,32 @@ export async function executeSnapshotAction(params: { : mode === "efficient" ? undefined : DEFAULT_AI_SNAPSHOT_MAX_CHARS - : undefined; - const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined; - const compact = typeof input.compact === "boolean" ? input.compact : undefined; - const depth = - typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined; - const selector = typeof input.selector === "string" ? input.selector.trim() : undefined; - const frame = typeof input.frame === "string" ? input.frame.trim() : undefined; + : hasMaxChars + ? maxChars + : undefined; + const snapshotQuery = { + ...(format ? { format } : {}), + targetId, + limit, + ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), + refs, + interactive, + compact, + depth, + selector, + frame, + labels, + mode, + }; const snapshot = proxyRequest ? ((await proxyRequest({ method: "GET", path: "/snapshot", profile, - query: { - format, - targetId, - limit, - ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), - refs, - interactive, - compact, - depth, - selector, - frame, - labels, - mode, - }, + query: snapshotQuery, })) as Awaited>) : await browserSnapshot(baseUrl, { - format, - targetId, - limit, - ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), - refs, - interactive, - compact, - depth, - selector, - frame, - labels, - mode, + ...snapshotQuery, profile, }); if (snapshot.format === "ai") { diff --git a/src/agents/tools/browser-tool.test.ts b/src/agents/tools/browser-tool.test.ts index 79358cf166564..81996afb41990 100644 --- a/src/agents/tools/browser-tool.test.ts +++ b/src/agents/tools/browser-tool.test.ts @@ -127,7 +127,7 @@ function registerBrowserToolAfterEachReset() { } async function runSnapshotToolCall(params: { - snapshotFormat: "ai" | "aria"; + snapshotFormat?: "ai" | "aria"; refs?: "aria" | "dom"; maxChars?: number; profile?: string; @@ -243,6 +243,23 @@ describe("browser tool snapshot maxChars", () => { ); }); + it("lets the server choose snapshot format when the user does not request one", async () => { + const tool = createBrowserTool(); + await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" }); + + expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + profile: "chrome", + }), + ); + const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as + | { format?: string; maxChars?: number } + | undefined; + expect(opts?.format).toBeUndefined(); + expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false); + }); + it("routes to node proxy when target=node", async () => { mockSingleBrowserProxyNode(); const tool = createBrowserTool(); @@ -250,15 +267,44 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", - { timeoutMs: 20000 }, + { timeoutMs: 25000 }, expect.objectContaining({ nodeId: "node-1", command: "browser.proxy", + params: expect.objectContaining({ + timeoutMs: 20000, + }), }), ); expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); }); + it("gives node.invoke extra slack beyond the default proxy timeout", async () => { + mockSingleBrowserProxyNode(); + gatewayMocks.callGatewayTool.mockResolvedValueOnce({ + ok: true, + payload: { + result: { ok: true, running: true }, + }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "dialog", + target: "node", + accept: true, + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 25000 }, + expect.objectContaining({ + params: expect.objectContaining({ + timeoutMs: 20000, + }), + }), + ); + }); + it("keeps sandbox bridge url when node proxy is available", async () => { mockSingleBrowserProxyNode(); const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" }); diff --git a/src/agents/tools/browser-tool.ts b/src/agents/tools/browser-tool.ts index 80faf99a1e425..200013ff1a722 100644 --- a/src/agents/tools/browser-tool.ts +++ b/src/agents/tools/browser-tool.ts @@ -115,6 +115,7 @@ type BrowserProxyResult = { }; const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; +const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000; type BrowserNodeTarget = { nodeId: string; @@ -206,10 +207,11 @@ async function callBrowserProxy(params: { timeoutMs?: number; profile?: string; }): Promise { - const gatewayTimeoutMs = + const proxyTimeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? Math.max(1, Math.floor(params.timeoutMs)) : DEFAULT_BROWSER_PROXY_TIMEOUT_MS; + const gatewayTimeoutMs = proxyTimeoutMs + BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS; const payload = await callGatewayTool<{ payloadJSON?: string; payload?: string }>( "node.invoke", { timeoutMs: gatewayTimeoutMs }, @@ -221,7 +223,7 @@ async function callBrowserProxy(params: { path: params.path, query: params.query, body: params.body, - timeoutMs: params.timeoutMs, + timeoutMs: proxyTimeoutMs, profile: params.profile, }, idempotencyKey: crypto.randomUUID(), diff --git a/src/browser/chrome-extension-background-utils.test.ts b/src/browser/chrome-extension-background-utils.test.ts index 74b767cb26994..b22b602116cf2 100644 --- a/src/browser/chrome-extension-background-utils.test.ts +++ b/src/browser/chrome-extension-background-utils.test.ts @@ -4,6 +4,11 @@ import { describe, expect, it } from "vitest"; type BackgroundUtilsModule = { buildRelayWsUrl: (port: number, gatewayToken: string) => Promise; deriveRelayToken: (gatewayToken: string, port: number) => Promise; + isLastRemainingTab: ( + allTabs: Array<{ id?: number | undefined } | null | undefined>, + tabIdToClose: number, + ) => boolean; + isMissingTabError: (err: unknown) => boolean; isRetryableReconnectError: (err: unknown) => boolean; reconnectDelayMs: ( attempt: number, @@ -26,8 +31,14 @@ async function loadBackgroundUtils(): Promise { } } -const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } = - await loadBackgroundUtils(); +const { + buildRelayWsUrl, + deriveRelayToken, + isLastRemainingTab, + isMissingTabError, + isRetryableReconnectError, + reconnectDelayMs, +} = await loadBackgroundUtils(); describe("chrome extension background utils", () => { it("derives relay token as HMAC-SHA256 of gateway token and port", async () => { @@ -107,4 +118,16 @@ describe("chrome extension background utils", () => { expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true); expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true); }); + + it("recognizes missing-tab debugger errors", () => { + expect(isMissingTabError(new Error("No tab with given id"))).toBe(true); + expect(isMissingTabError(new Error("tab not found"))).toBe(true); + expect(isMissingTabError(new Error("Cannot access a chrome:// URL"))).toBe(false); + }); + + it("blocks closing the final remaining tab only", () => { + expect(isLastRemainingTab([{ id: 7 }], 7)).toBe(true); + expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 7)).toBe(false); + expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 8)).toBe(false); + }); }); diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts index 7922fd94820be..a4f95c230071f 100644 --- a/src/browser/client.test.ts +++ b/src/browser/client.test.ts @@ -101,6 +101,21 @@ describe("browser client", () => { expect(parsed.searchParams.get("refs")).toBe("aria"); }); + it("omits format when the caller wants server-side snapshot capability defaults", async () => { + const calls: string[] = []; + stubSnapshotFetch(calls); + + await browserSnapshot("http://127.0.0.1:18791", { + profile: "chrome", + }); + + const snapshotCall = calls.find((url) => url.includes("/snapshot?")); + expect(snapshotCall).toBeTruthy(); + const parsed = new URL(snapshotCall as string); + expect(parsed.searchParams.get("format")).toBeNull(); + expect(parsed.searchParams.get("profile")).toBe("chrome"); + }); + it("uses the expected endpoints + methods for common calls", async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; diff --git a/src/browser/client.ts b/src/browser/client.ts index 5085825cb6e69..76b799bde64e9 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -276,7 +276,7 @@ export async function browserTabAction( export async function browserSnapshot( baseUrl: string | undefined, opts: { - format: "aria" | "ai"; + format?: "aria" | "ai"; targetId?: string; limit?: number; maxChars?: number; @@ -292,7 +292,9 @@ export async function browserSnapshot( }, ): Promise { const q = new URLSearchParams(); - q.set("format", opts.format); + if (opts.format) { + q.set("format", opts.format); + } if (opts.targetId) { q.set("targetId", opts.targetId); } diff --git a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts index f1909ad33fb85..43f1a6c7e09aa 100644 --- a/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts +++ b/src/browser/pw-session.get-page-for-targetid.extension-fallback.test.ts @@ -115,4 +115,67 @@ describe("pw-session getPageForTargetId", () => { fetchSpy.mockRestore(); } }); + + it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => { + const pageOn = vi.fn(); + const contextOn = vi.fn(); + const browserOn = vi.fn(); + const browserClose = vi.fn(async () => {}); + const newCDPSession = vi.fn(async () => { + throw new Error("Target.attachToBrowserTarget: Not allowed"); + }); + + const context = { + pages: () => [], + on: contextOn, + newCDPSession, + } as unknown as import("playwright-core").BrowserContext; + + const pageA = { + on: pageOn, + context: () => context, + url: () => "https://alpha.example", + } as unknown as import("playwright-core").Page; + const pageB = { + on: pageOn, + context: () => context, + url: () => "https://beta.example", + } as unknown as import("playwright-core").Page; + + (context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB]; + + const browser = { + contexts: () => [context], + on: browserOn, + close: browserClose, + } as unknown as import("playwright-core").Browser; + + connectOverCdpSpy.mockResolvedValue(browser); + getChromeWebSocketUrlSpy.mockResolvedValue(null); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + fetchSpy + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ Browser: "OpenClaw/extension-relay" }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { id: "TARGET_A", url: "https://alpha.example" }, + { id: "TARGET_B", url: "https://beta.example" }, + ], + } as Response); + + try { + const resolved = await getPageForTargetId({ + cdpUrl: "http://127.0.0.1:19993", + targetId: "TARGET_B", + }); + expect(resolved).toBe(pageB); + expect(newCDPSession).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); }); diff --git a/src/browser/pw-session.page-cdp.test.ts b/src/browser/pw-session.page-cdp.test.ts new file mode 100644 index 0000000000000..1347cca20a13f --- /dev/null +++ b/src/browser/pw-session.page-cdp.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const cdpHelperMocks = vi.hoisted(() => ({ + fetchJson: vi.fn(), + withCdpSocket: vi.fn(), +})); + +const chromeMocks = vi.hoisted(() => ({ + getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"), +})); + +vi.mock("./cdp.helpers.js", async () => { + const actual = await vi.importActual("./cdp.helpers.js"); + return { + ...actual, + fetchJson: cdpHelperMocks.fetchJson, + withCdpSocket: cdpHelperMocks.withCdpSocket, + }; +}); + +vi.mock("./chrome.js", () => chromeMocks); + +import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; + +describe("pw-session page-scoped CDP client", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => { + cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" }); + const send = vi.fn(async () => ({ ok: true })); + cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send)); + const newCDPSession = vi.fn(); + const page = { + context: () => ({ + newCDPSession, + }), + }; + + await withPageScopedCdpClient({ + cdpUrl: "http://127.0.0.1:18792", + page: page as never, + targetId: "tab-1", + fn: async (pageSend) => { + await pageSend("Page.bringToFront", { foo: "bar" }); + }, + }); + + expect(send).toHaveBeenCalledWith("Page.bringToFront", { + foo: "bar", + targetId: "tab-1", + }); + expect(newCDPSession).not.toHaveBeenCalled(); + }); + + it("falls back to Playwright page sessions for non-relay endpoints", async () => { + cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" }); + const sessionSend = vi.fn(async () => ({ ok: true })); + const sessionDetach = vi.fn(async () => {}); + const newCDPSession = vi.fn(async () => ({ + send: sessionSend, + detach: sessionDetach, + })); + const page = { + context: () => ({ + newCDPSession, + }), + }; + + await withPageScopedCdpClient({ + cdpUrl: "http://127.0.0.1:9222", + page: page as never, + targetId: "tab-1", + fn: async (pageSend) => { + await pageSend("Emulation.setLocaleOverride", { locale: "en-US" }); + }, + }); + + expect(newCDPSession).toHaveBeenCalledWith(page); + expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" }); + expect(sessionDetach).toHaveBeenCalledTimes(1); + expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled(); + }); + + it("caches extension-relay endpoint detection by cdpUrl", async () => { + cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" }); + + await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true); + await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true); + + expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/browser/pw-session.page-cdp.ts b/src/browser/pw-session.page-cdp.ts new file mode 100644 index 0000000000000..8c2109293cd97 --- /dev/null +++ b/src/browser/pw-session.page-cdp.ts @@ -0,0 +1,81 @@ +import type { CDPSession, Page } from "playwright-core"; +import { + appendCdpPath, + fetchJson, + normalizeCdpHttpBaseForJsonEndpoints, + withCdpSocket, +} from "./cdp.helpers.js"; +import { getChromeWebSocketUrl } from "./chrome.js"; + +const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay"; + +type PageCdpSend = (method: string, params?: Record) => Promise; + +const extensionRelayByCdpUrl = new Map(); + +function normalizeCdpUrl(raw: string) { + return raw.replace(/\/$/, ""); +} + +export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise { + const normalized = normalizeCdpUrl(cdpUrl); + const cached = extensionRelayByCdpUrl.get(normalized); + if (cached !== undefined) { + return cached; + } + + try { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(normalized); + const version = await fetchJson<{ Browser?: string }>( + appendCdpPath(cdpHttpBase, "/json/version"), + 2000, + ); + const isRelay = String(version?.Browser ?? "").trim() === OPENCLAW_EXTENSION_RELAY_BROWSER; + extensionRelayByCdpUrl.set(normalized, isRelay); + return isRelay; + } catch { + extensionRelayByCdpUrl.set(normalized, false); + return false; + } +} + +async function withPlaywrightPageCdpSession( + page: Page, + fn: (session: CDPSession) => Promise, +): Promise { + const session = await page.context().newCDPSession(page); + try { + return await fn(session); + } finally { + await session.detach().catch(() => {}); + } +} + +export async function withPageScopedCdpClient(opts: { + cdpUrl: string; + page: Page; + targetId?: string; + fn: (send: PageCdpSend) => Promise; +}): Promise { + const targetId = opts.targetId?.trim(); + if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) { + const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000); + if (!wsUrl) { + throw new Error("CDP websocket unavailable"); + } + return await withCdpSocket(wsUrl, async (send) => { + return await opts.fn((method, params) => send(method, { ...params, targetId })); + }); + } + + return await withPlaywrightPageCdpSession(opts.page, async (session) => { + return await opts.fn((method, params) => + ( + session.send as unknown as ( + method: string, + params?: Record, + ) => Promise + )(method, params), + ); + }); +} diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index a5f1b11ec02c6..53f9c24114248 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -24,6 +24,7 @@ import { assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; +import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; export type BrowserConsoleMessage = { type: string; @@ -398,14 +399,70 @@ async function pageTargetId(page: Page): Promise { } } +function matchPageByTargetList( + pages: Page[], + targets: Array<{ id: string; url: string; title?: string }>, + targetId: string, +): Page | null { + const target = targets.find((entry) => entry.id === targetId); + if (!target) { + return null; + } + + const urlMatch = pages.filter((page) => page.url() === target.url); + if (urlMatch.length === 1) { + return urlMatch[0] ?? null; + } + if (urlMatch.length > 1) { + const sameUrlTargets = targets.filter((entry) => entry.url === target.url); + if (sameUrlTargets.length === urlMatch.length) { + const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId); + if (idx >= 0 && idx < urlMatch.length) { + return urlMatch[idx] ?? null; + } + } + } + return null; +} + +async function findPageByTargetIdViaTargetList( + pages: Page[], + targetId: string, + cdpUrl: string, +): Promise { + const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); + const targets = await fetchJson< + Array<{ + id: string; + url: string; + title?: string; + }> + >(appendCdpPath(cdpHttpBase, "/json/list"), 2000); + return matchPageByTargetList(pages, targets, targetId); +} + async function findPageByTargetId( browser: Browser, targetId: string, cdpUrl?: string, ): Promise { const pages = await getAllPages(browser); + const isExtensionRelay = cdpUrl + ? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false) + : false; + if (cdpUrl && isExtensionRelay) { + try { + const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); + if (matched) { + return matched; + } + } catch { + // Ignore fetch errors and fall through to best-effort single-page fallback. + } + return pages.length === 1 ? (pages[0] ?? null) : null; + } + let resolvedViaCdp = false; - // First, try the standard CDP session approach for (const page of pages) { let tid: string | null = null; try { @@ -418,46 +475,16 @@ async function findPageByTargetId( return page; } } - // Extension relays can block CDP attachment APIs entirely. If that happens and - // Playwright only exposes one page, return it as the best available mapping. - if (!resolvedViaCdp && pages.length === 1) { - return pages[0]; - } - // If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget), - // fall back to URL-based matching using the /json/list endpoint if (cdpUrl) { try { - const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); - const targets = await fetchJson< - Array<{ - id: string; - url: string; - title?: string; - }> - >(appendCdpPath(cdpHttpBase, "/json/list"), 2000); - const target = targets.find((t) => t.id === targetId); - if (target) { - // Try to find a page with matching URL - const urlMatch = pages.filter((p) => p.url() === target.url); - if (urlMatch.length === 1) { - return urlMatch[0]; - } - // If multiple URL matches, use index-based matching as fallback - // This works when Playwright and the relay enumerate tabs in the same order - if (urlMatch.length > 1) { - const sameUrlTargets = targets.filter((t) => t.url === target.url); - if (sameUrlTargets.length === urlMatch.length) { - const idx = sameUrlTargets.findIndex((t) => t.id === targetId); - if (idx >= 0 && idx < urlMatch.length) { - return urlMatch[idx]; - } - } - } - } + return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); } catch { - // Ignore fetch errors and fall through to return null + // Ignore fetch errors and fall through to return null. } } + if (!resolvedViaCdp && pages.length === 1) { + return pages[0] ?? null; + } return null; } @@ -806,14 +833,18 @@ export async function focusPageByTargetIdViaPlaywright(opts: { try { await page.bringToFront(); } catch (err) { - const session = await page.context().newCDPSession(page); try { - await session.send("Page.bringToFront"); + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + await send("Page.bringToFront"); + }, + }); return; } catch { throw err; - } finally { - await session.detach().catch(() => {}); } } } diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts index 419aba6357d91..b3dc8dec7b050 100644 --- a/src/browser/pw-tools-core.snapshot.ts +++ b/src/browser/pw-tools-core.snapshot.ts @@ -19,6 +19,7 @@ import { storeRoleRefsForTarget, type WithSnapshotForAI, } from "./pw-session.js"; +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; export async function snapshotAriaViaPlaywright(opts: { cdpUrl: string; @@ -31,17 +32,21 @@ export async function snapshotAriaViaPlaywright(opts: { targetId: opts.targetId, }); ensurePageState(page); - const session = await page.context().newCDPSession(page); - try { - await session.send("Accessibility.enable").catch(() => {}); - const res = (await session.send("Accessibility.getFullAXTree")) as { - nodes?: RawAXNode[]; - }; - const nodes = Array.isArray(res?.nodes) ? res.nodes : []; - return { nodes: formatAriaSnapshot(nodes, limit) }; - } finally { - await session.detach().catch(() => {}); - } + const res = (await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + await send("Accessibility.enable").catch(() => {}); + return (await send("Accessibility.getFullAXTree")) as { + nodes?: RawAXNode[]; + }; + }, + })) as { + nodes?: RawAXNode[]; + }; + const nodes = Array.isArray(res?.nodes) ? res.nodes : []; + return { nodes: formatAriaSnapshot(nodes, limit) }; } export async function snapshotAiViaPlaywright(opts: { diff --git a/src/browser/pw-tools-core.state.ts b/src/browser/pw-tools-core.state.ts index aeeb8859d8ff9..580fadba10886 100644 --- a/src/browser/pw-tools-core.state.ts +++ b/src/browser/pw-tools-core.state.ts @@ -1,15 +1,6 @@ -import type { CDPSession, Page } from "playwright-core"; import { devices as playwrightDevices } from "playwright-core"; import { ensurePageState, getPageForTargetId } from "./pw-session.js"; - -async function withCdpSession(page: Page, fn: (session: CDPSession) => Promise): Promise { - const session = await page.context().newCDPSession(page); - try { - return await fn(session); - } finally { - await session.detach().catch(() => {}); - } -} +import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; export async function setOfflineViaPlaywright(opts: { cdpUrl: string; @@ -112,15 +103,20 @@ export async function setLocaleViaPlaywright(opts: { if (!locale) { throw new Error("locale is required"); } - await withCdpSession(page, async (session) => { - try { - await session.send("Emulation.setLocaleOverride", { locale }); - } catch (err) { - if (String(err).includes("Another locale override is already in effect")) { - return; + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + try { + await send("Emulation.setLocaleOverride", { locale }); + } catch (err) { + if (String(err).includes("Another locale override is already in effect")) { + return; + } + throw err; } - throw err; - } + }, }); } @@ -135,19 +131,24 @@ export async function setTimezoneViaPlaywright(opts: { if (!timezoneId) { throw new Error("timezoneId is required"); } - await withCdpSession(page, async (session) => { - try { - await session.send("Emulation.setTimezoneOverride", { timezoneId }); - } catch (err) { - const msg = String(err); - if (msg.includes("Timezone override is already in effect")) { - return; - } - if (msg.includes("Invalid timezone")) { - throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err }); + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + try { + await send("Emulation.setTimezoneOverride", { timezoneId }); + } catch (err) { + const msg = String(err); + if (msg.includes("Timezone override is already in effect")) { + return; + } + if (msg.includes("Invalid timezone")) { + throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err }); + } + throw err; } - throw err; - } + }, }); } @@ -183,27 +184,32 @@ export async function setDeviceViaPlaywright(opts: { }); } - await withCdpSession(page, async (session) => { - if (descriptor.userAgent || descriptor.locale) { - await session.send("Emulation.setUserAgentOverride", { - userAgent: descriptor.userAgent ?? "", - acceptLanguage: descriptor.locale ?? undefined, - }); - } - if (descriptor.viewport) { - await session.send("Emulation.setDeviceMetricsOverride", { - mobile: Boolean(descriptor.isMobile), - width: descriptor.viewport.width, - height: descriptor.viewport.height, - deviceScaleFactor: descriptor.deviceScaleFactor ?? 1, - screenWidth: descriptor.viewport.width, - screenHeight: descriptor.viewport.height, - }); - } - if (descriptor.hasTouch) { - await session.send("Emulation.setTouchEmulationEnabled", { - enabled: true, - }); - } + await withPageScopedCdpClient({ + cdpUrl: opts.cdpUrl, + page, + targetId: opts.targetId, + fn: async (send) => { + if (descriptor.userAgent || descriptor.locale) { + await send("Emulation.setUserAgentOverride", { + userAgent: descriptor.userAgent ?? "", + acceptLanguage: descriptor.locale ?? undefined, + }); + } + if (descriptor.viewport) { + await send("Emulation.setDeviceMetricsOverride", { + mobile: Boolean(descriptor.isMobile), + width: descriptor.viewport.width, + height: descriptor.viewport.height, + deviceScaleFactor: descriptor.deviceScaleFactor ?? 1, + screenWidth: descriptor.viewport.width, + screenHeight: descriptor.viewport.height, + }); + } + if (descriptor.hasTouch) { + await send("Emulation.setTouchEmulationEnabled", { + enabled: true, + }); + } + }, }); } diff --git a/src/browser/server-lifecycle.test.ts b/src/browser/server-lifecycle.test.ts index 9c11a3d48f843..e2395f99f044d 100644 --- a/src/browser/server-lifecycle.test.ts +++ b/src/browser/server-lifecycle.test.ts @@ -5,17 +5,27 @@ const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted( ensureChromeExtensionRelayServerMock: vi.fn(), })); +const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({ + stopOpenClawChromeMock: vi.fn(async () => {}), + stopChromeExtensionRelayServerMock: vi.fn(async () => true), +})); + const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({ createBrowserRouteContextMock: vi.fn(), listKnownProfileNamesMock: vi.fn(), })); +vi.mock("./chrome.js", () => ({ + stopOpenClawChrome: stopOpenClawChromeMock, +})); + vi.mock("./config.js", () => ({ resolveProfile: resolveProfileMock, })); vi.mock("./extension-relay.js", () => ({ ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock, + stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock, })); vi.mock("./server-context.js", () => ({ @@ -76,6 +86,8 @@ describe("stopKnownBrowserProfiles", () => { beforeEach(() => { createBrowserRouteContextMock.mockClear(); listKnownProfileNamesMock.mockClear(); + stopOpenClawChromeMock.mockClear(); + stopChromeExtensionRelayServerMock.mockClear(); }); it("stops all known profiles and ignores per-profile failures", async () => { @@ -104,6 +116,53 @@ describe("stopKnownBrowserProfiles", () => { expect(onWarn).not.toHaveBeenCalled(); }); + it("stops tracked runtime browsers even when the profile no longer resolves", async () => { + listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]); + createBrowserRouteContextMock.mockReturnValue({ + forProfile: vi.fn(() => { + throw new Error("profile not found"); + }), + }); + const localRuntime = { + profile: { + name: "deleted-local", + driver: "openclaw", + }, + running: { + pid: 42, + cdpPort: 18888, + }, + }; + const launchedBrowser = localRuntime.running; + const extensionRuntime = { + profile: { + name: "deleted-extension", + driver: "extension", + cdpUrl: "http://127.0.0.1:19999", + }, + running: null, + }; + const profiles = new Map([ + ["deleted-local", localRuntime], + ["deleted-extension", extensionRuntime], + ]); + const state = { + resolved: { profiles: {} }, + profiles, + }; + + await stopKnownBrowserProfiles({ + getState: () => state as never, + onWarn: vi.fn(), + }); + + expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser); + expect(localRuntime.running).toBeNull(); + expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:19999", + }); + }); + it("warns when profile enumeration fails", async () => { listKnownProfileNamesMock.mockImplementation(() => { throw new Error("oops"); diff --git a/src/browser/server-lifecycle.ts b/src/browser/server-lifecycle.ts index 10a4569095a06..7053d924b6d64 100644 --- a/src/browser/server-lifecycle.ts +++ b/src/browser/server-lifecycle.ts @@ -1,6 +1,10 @@ +import { stopOpenClawChrome } from "./chrome.js"; import type { ResolvedBrowserConfig } from "./config.js"; import { resolveProfile } from "./config.js"; -import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; import { type BrowserServerState, createBrowserRouteContext, @@ -40,6 +44,18 @@ export async function stopKnownBrowserProfiles(params: { try { for (const name of listKnownProfileNames(current)) { try { + const runtime = current.profiles.get(name); + if (runtime?.running) { + await stopOpenClawChrome(runtime.running); + runtime.running = null; + continue; + } + if (runtime?.profile.driver === "extension") { + await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch( + () => false, + ); + continue; + } await ctx.forProfile(name).stopRunningBrowser(); } catch { // ignore diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts new file mode 100644 index 0000000000000..ca9232823c117 --- /dev/null +++ b/src/node-host/invoke-browser.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const controlServiceMocks = vi.hoisted(() => ({ + createBrowserControlContext: vi.fn(() => ({ control: true })), + startBrowserControlServiceFromConfig: vi.fn(async () => true), +})); + +const dispatcherMocks = vi.hoisted(() => ({ + dispatch: vi.fn(), + createBrowserRouteDispatcher: vi.fn(() => ({ + dispatch: dispatcherMocks.dispatch, + })), +})); + +const configMocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({ + browser: {}, + nodeHost: { browserProxy: { enabled: true } }, + })), +})); + +const browserConfigMocks = vi.hoisted(() => ({ + resolveBrowserConfig: vi.fn(() => ({ + enabled: true, + defaultProfile: "chrome", + })), +})); + +vi.mock("../browser/control-service.js", () => controlServiceMocks); +vi.mock("../browser/routes/dispatcher.js", () => dispatcherMocks); +vi.mock("../config/config.js", () => configMocks); +vi.mock("../browser/config.js", () => browserConfigMocks); +vi.mock("../media/mime.js", () => ({ + detectMime: vi.fn(async () => "image/png"), +})); + +import { runBrowserProxyCommand } from "./invoke-browser.js"; + +describe("runBrowserProxyCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.loadConfig.mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true } }, + }); + browserConfigMocks.resolveBrowserConfig.mockReturnValue({ + enabled: true, + defaultProfile: "chrome", + }); + controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true); + }); + + it("adds profile and browser status details on ws-backed timeouts", async () => { + dispatcherMocks.dispatch + .mockImplementationOnce(async () => { + await new Promise(() => {}); + }) + .mockResolvedValueOnce({ + status: 200, + body: { + running: true, + cdpHttp: true, + cdpReady: false, + cdpUrl: "http://127.0.0.1:18792", + }, + }); + + await expect( + runBrowserProxyCommand( + JSON.stringify({ + method: "GET", + path: "/snapshot", + profile: "chrome", + timeoutMs: 5, + }), + ), + ).rejects.toThrow( + /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, + ); + }); + + it("keeps non-timeout browser errors intact", async () => { + dispatcherMocks.dispatch.mockResolvedValue({ + status: 500, + body: { error: "tab not found" }, + }); + + await expect( + runBrowserProxyCommand( + JSON.stringify({ + method: "POST", + path: "/act", + profile: "chrome", + timeoutMs: 50, + }), + ), + ).rejects.toThrow("tab not found"); + }); +}); diff --git a/src/node-host/invoke-browser.ts b/src/node-host/invoke-browser.ts index 115fcef6717bc..8587dff72c33b 100644 --- a/src/node-host/invoke-browser.ts +++ b/src/node-host/invoke-browser.ts @@ -30,6 +30,8 @@ type BrowserProxyResult = { }; const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024; +const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; +const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750; function normalizeProfileAllowlist(raw?: string[]): string[] { return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : []; @@ -119,6 +121,87 @@ function decodeParams(raw?: string | null): T { return JSON.parse(raw) as T; } +function resolveBrowserProxyTimeout(timeoutMs?: number): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) + ? Math.max(1, Math.floor(timeoutMs)) + : DEFAULT_BROWSER_PROXY_TIMEOUT_MS; +} + +function isBrowserProxyTimeoutError(err: unknown): boolean { + return String(err).includes("browser proxy request timed out"); +} + +function isWsBackedBrowserProxyPath(path: string): boolean { + return ( + path === "/act" || + path === "/navigate" || + path === "/pdf" || + path === "/screenshot" || + path === "/snapshot" + ); +} + +async function readBrowserProxyStatus(params: { + dispatcher: ReturnType; + profile?: string; +}): Promise | null> { + const query = params.profile ? { profile: params.profile } : {}; + try { + const response = await withTimeout( + (signal) => + params.dispatcher.dispatch({ + method: "GET", + path: "/", + query, + signal, + }), + BROWSER_PROXY_STATUS_TIMEOUT_MS, + "browser proxy status", + ); + if (response.status >= 400 || !response.body || typeof response.body !== "object") { + return null; + } + const body = response.body as Record; + return { + running: body.running, + cdpHttp: body.cdpHttp, + cdpReady: body.cdpReady, + cdpUrl: body.cdpUrl, + }; + } catch { + return null; + } +} + +function formatBrowserProxyTimeoutMessage(params: { + method: string; + path: string; + profile?: string; + timeoutMs: number; + wsBacked: boolean; + status: Record | null; +}): string { + const parts = [ + `browser proxy timed out for ${params.method} ${params.path} after ${params.timeoutMs}ms`, + params.wsBacked ? "ws-backed browser action" : "browser action", + ]; + if (params.profile) { + parts.push(`profile=${params.profile}`); + } + if (params.status) { + const statusParts = [ + `running=${String(params.status.running)}`, + `cdpHttp=${String(params.status.cdpHttp)}`, + `cdpReady=${String(params.status.cdpReady)}`, + ]; + if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) { + statusParts.push(`cdpUrl=${params.status.cdpUrl}`); + } + parts.push(`status(${statusParts.join(", ")})`); + } + return parts.join("; "); +} + export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise { const params = decodeParams(paramsJSON); const pathValue = typeof params.path === "string" ? params.path.trim() : ""; @@ -151,6 +234,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`; const body = params.body; + const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs); const query: Record = {}; if (requestedProfile) { query.profile = requestedProfile; @@ -164,18 +248,41 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis } const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext()); - const response = await withTimeout( - (signal) => - dispatcher.dispatch({ - method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET", + let response; + try { + response = await withTimeout( + (signal) => + dispatcher.dispatch({ + method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET", + path, + query, + body, + signal, + }), + timeoutMs, + "browser proxy request", + ); + } catch (err) { + if (!isBrowserProxyTimeoutError(err)) { + throw err; + } + const profileForStatus = requestedProfile || resolved.defaultProfile; + const status = await readBrowserProxyStatus({ + dispatcher, + profile: path === "/profiles" ? undefined : profileForStatus, + }); + throw new Error( + formatBrowserProxyTimeoutMessage({ + method, path, - query, - body, - signal, + profile: path === "/profiles" ? undefined : profileForStatus || undefined, + timeoutMs, + wsBacked: isWsBackedBrowserProxyPath(path), + status, }), - params.timeoutMs, - "browser proxy request", - ); + { cause: err }, + ); + } if (response.status >= 400) { const message = response.body && typeof response.body === "object" && "error" in response.body From 4ff4ed7ec97450b502ca3f456e3af8c7c19eff43 Mon Sep 17 00:00:00 2001 From: bbblending <122739024+bbblending@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:49:15 +0800 Subject: [PATCH 190/311] fix(config): refresh runtime snapshot from disk after write. Fixes #37175 (#37313) Merged via squash. Prepared head SHA: 69e1861abf97d20c787a790d37e68c9e3ae2cb1d Co-authored-by: bbblending <122739024+bbblending@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/cli/daemon-cli/lifecycle.test.ts | 21 +- src/config/config.ts | 2 + src/config/io.runtime-snapshot-write.test.ts | 115 +++++++++ src/config/io.ts | 65 ++++- src/secrets/runtime.test.ts | 246 ++++++++++++++++++- src/secrets/runtime.ts | 85 ++++++- 7 files changed, 516 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e68bb2b046962..7c61e76faa148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. - TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. - Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232) +- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending. ## 2026.3.7 diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index f1e87fc4938c0..3f0ed6d531c30 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> ->(); +const probeGateway = + vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> + >(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/config/config.ts b/src/config/config.ts index 2c7d6a75f1b99..7caaa15a95f59 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,5 +1,6 @@ export { clearConfigCache, + ConfigRuntimeRefreshError, clearRuntimeConfigSnapshot, createConfigIO, getRuntimeConfigSnapshot, @@ -10,6 +11,7 @@ export { readConfigFileSnapshot, readConfigFileSnapshotForWrite, resolveConfigSnapshotHash, + setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index b9ea7d51edbb1..71ddbbb8de324 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -7,6 +7,7 @@ import { clearRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, loadConfig, + setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; @@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig { } function resetRuntimeConfigState(): void { + setRuntimeConfigSnapshotRefreshHandler(null); clearRuntimeConfigSnapshot(); clearConfigCache(); } @@ -96,4 +98,117 @@ describe("runtime config snapshot writes", () => { } }); }); + + it("refreshes the runtime snapshot after writes so follow-up reads see persisted changes", async () => { + await withTempHome("openclaw-config-runtime-write-refresh-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + models: [], + }, + }, + }, + }; + const nextRuntimeConfig: OpenClawConfig = { + ...runtimeConfig, + gateway: { auth: { mode: "token" as const } }, + }; + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8"); + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + expect(loadConfig().gateway?.auth).toBeUndefined(); + + await writeConfigFile(nextRuntimeConfig); + + expect(loadConfig().gateway?.auth).toEqual({ mode: "token" }); + expect(loadConfig().models?.providers?.openai?.apiKey).toBeDefined(); + + let persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as { + gateway?: { auth?: unknown }; + models?: { providers?: { openai?: { apiKey?: unknown } } }; + }; + expect(persisted.gateway?.auth).toEqual({ mode: "token" }); + // Post-write secret-ref: apiKey must stay as source ref (not plaintext). + expect(persisted.models?.providers?.openai?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }); + + // Follow-up write: runtimeConfigSourceSnapshot must be restored so second write + // still runs secret-preservation merge-patch and keeps apiKey as ref (not plaintext). + await writeConfigFile(loadConfig()); + persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as { + gateway?: { auth?: unknown }; + models?: { providers?: { openai?: { apiKey?: unknown } } }; + }; + expect(persisted.models?.providers?.openai?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => { + await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const sourceConfig = createSourceConfig(); + const runtimeConfig = createRuntimeConfig(); + const nextRuntimeConfig: OpenClawConfig = { + ...runtimeConfig, + gateway: { auth: { mode: "token" as const } }, + }; + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8"); + + let releaseRefresh!: () => void; + const refreshPending = new Promise((resolve) => { + releaseRefresh = () => resolve(true); + }); + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + setRuntimeConfigSnapshotRefreshHandler({ + refresh: async ({ sourceConfig: refreshedSource }) => { + expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" }); + expect(loadConfig().gateway?.auth).toBeUndefined(); + return await refreshPending; + }, + }); + + const writePromise = writeConfigFile(nextRuntimeConfig); + await Promise.resolve(); + + expect(loadConfig().gateway?.auth).toBeUndefined(); + releaseRefresh(); + await writePromise; + } finally { + resetRuntimeConfigState(); + } + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 7b1af76438a56..a4ec4cd430c8b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -140,6 +140,22 @@ export type ReadConfigFileSnapshotForWriteResult = { writeOptions: ConfigWriteOptions; }; +export type RuntimeConfigSnapshotRefreshParams = { + sourceConfig: OpenClawConfig; +}; + +export type RuntimeConfigSnapshotRefreshHandler = { + refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise; + clearOnRefreshFailure?: () => void; +}; + +export class ConfigRuntimeRefreshError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ConfigRuntimeRefreshError"; + } +} + function hashConfigRaw(raw: string | null): string { return crypto .createHash("sha256") @@ -1306,6 +1322,7 @@ let configCache: { } | null = null; let runtimeConfigSnapshot: OpenClawConfig | null = null; let runtimeConfigSourceSnapshot: OpenClawConfig | null = null; +let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null; function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number { const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim(); @@ -1356,6 +1373,12 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { return runtimeConfigSourceSnapshot; } +export function setRuntimeConfigSnapshotRefreshHandler( + refreshHandler: RuntimeConfigSnapshotRefreshHandler | null, +): void { + runtimeConfigSnapshotRefreshHandler = refreshHandler; +} + export function loadConfig(): OpenClawConfig { if (runtimeConfigSnapshot) { return runtimeConfigSnapshot; @@ -1402,9 +1425,11 @@ export async function writeConfigFile( ): Promise { const io = createConfigIO(); let nextCfg = cfg; - if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) { - const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg); - nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch)); + const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot); + const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot); + if (hadBothSnapshots) { + const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg); + nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch)); } const sameConfigPath = options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath; @@ -1412,4 +1437,38 @@ export async function writeConfigFile( envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined, unsetPaths: options.unsetPaths, }); + // Keep the last-known-good runtime snapshot active until the specialized refresh path + // succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh. + const refreshHandler = runtimeConfigSnapshotRefreshHandler; + if (refreshHandler) { + try { + const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg }); + if (refreshed) { + return; + } + } catch (error) { + try { + refreshHandler.clearOnRefreshFailure?.(); + } catch { + // Keep the original refresh failure as the surfaced error. + } + const detail = error instanceof Error ? error.message : String(error); + throw new ConfigRuntimeRefreshError( + `Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`, + { cause: error }, + ); + } + } + if (hadBothSnapshots) { + // Refresh both snapshots from disk atomically so follow-up reads get normalized config and + // subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true). + const fresh = io.loadConfig(); + setRuntimeConfigSnapshot(fresh, nextCfg); + return; + } + if (hadRuntimeSnapshot) { + clearRuntimeConfigSnapshot(); + } + // When we had no runtime snapshot, keep callers reading from disk/cache so external/manual + // edits to openclaw.json remain visible (no stale snapshot). } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 1d9189f843c6e..02b5f84f9a6f8 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -3,10 +3,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { loadConfig, type OpenClawConfig, writeConfigFile } from "../config/config.js"; +import { withTempHome } from "../config/home-env.test-harness.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot, + getActiveSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot, } from "./runtime.js"; @@ -527,6 +529,248 @@ describe("secrets runtime snapshot", () => { }); }); + it("keeps active secrets runtime snapshots resolved after config writes", async () => { + await withTempHome("openclaw-secrets-runtime-write-", async (home) => { + const configDir = path.join(home, ".openclaw"); + const secretFile = path.join(configDir, "secrets.json"); + const agentDir = path.join(configDir, "agents", "main", "agent"); + const authStorePath = path.join(agentDir, "auth-profiles.json"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.chmod(configDir, 0o700).catch(() => { + // best-effort on tmp dirs that already have secure perms + }); + await fs.writeFile( + secretFile, + `${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, // pragma: allowlist secret + { encoding: "utf8", mode: 0o600 }, + ); + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + }, + }, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + secrets: { + providers: { + default: { source: "file", path: secretFile, mode: "json" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + models: [], + }, + }, + }, + }), + agentDirs: [agentDir], + }); + + activateSecretsRuntimeSnapshot(prepared); + + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); + expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-file-runtime", + }); + + await writeConfigFile({ + ...loadConfig(), + gateway: { auth: { mode: "token" } }, + }); + + expect(loadConfig().gateway?.auth).toEqual({ mode: "token" }); + expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); + expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({ + type: "api_key", + key: "sk-file-runtime", + }); + }); + }); + + it("clears active secrets runtime state and throws when refresh fails after a write", async () => { + await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => { + const configDir = path.join(home, ".openclaw"); + const secretFile = path.join(configDir, "secrets.json"); + const agentDir = path.join(configDir, "agents", "main", "agent"); + const authStorePath = path.join(agentDir, "auth-profiles.json"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.chmod(configDir, 0o700).catch(() => { + // best-effort on tmp dirs that already have secure perms + }); + await fs.writeFile( + secretFile, + `${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + }, + }, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + + let loadAuthStoreCalls = 0; + const loadAuthStore = () => { + loadAuthStoreCalls += 1; + if (loadAuthStoreCalls > 1) { + throw new Error("simulated secrets runtime refresh failure"); + } + return loadAuthStoreWithProfiles({ + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + }, + }); + }; + + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + secrets: { + providers: { + default: { source: "file", path: secretFile, mode: "json" }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + models: [], + }, + }, + }, + }), + agentDirs: [agentDir], + loadAuthStore, + }); + + activateSecretsRuntimeSnapshot(prepared); + + await expect( + writeConfigFile({ + ...loadConfig(), + gateway: { auth: { mode: "token" } }, + }), + ).rejects.toThrow( + /runtime snapshot refresh failed: simulated secrets runtime refresh failure/i, + ); + + expect(getActiveSecretsRuntimeSnapshot()).toBeNull(); + expect(loadConfig().gateway?.auth).toEqual({ mode: "token" }); + expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({ + source: "file", + provider: "default", + id: "/providers/openai/apiKey", + }); + + const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"]; + expect(persistedStore).toMatchObject({ + type: "api_key", + keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" }, + }); + expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined(); + }); + }); + + it("recomputes config-derived agent dirs when refreshing active secrets runtime snapshots", async () => { + await withTempHome("openclaw-secrets-runtime-agent-dirs-", async (home) => { + const mainAgentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + const opsAgentDir = path.join(home, ".openclaw", "agents", "ops", "agent"); + await fs.mkdir(mainAgentDir, { recursive: true }); + await fs.mkdir(opsAgentDir, { recursive: true }); + await fs.writeFile( + path.join(mainAgentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + }, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + await fs.writeFile( + path.join(opsAgentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "anthropic:ops": { + type: "api_key", + provider: "anthropic", + keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" }, + }, + }, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + + const prepared = await prepareSecretsRuntimeSnapshot({ + config: asConfig({}), + env: { + OPENAI_API_KEY: "sk-main-runtime", // pragma: allowlist secret + ANTHROPIC_API_KEY: "sk-ops-runtime", // pragma: allowlist secret + }, + }); + + activateSecretsRuntimeSnapshot(prepared); + expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toBeUndefined(); + + await writeConfigFile({ + agents: { + list: [{ id: "ops", agentDir: opsAgentDir }], + }, + }); + + expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toMatchObject({ + type: "api_key", + key: "sk-ops-runtime", + keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" }, + }); + }); + }); + it("skips inactive-surface refs and emits diagnostics", async () => { const config = asConfig({ agents: { diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 8faef0436cb3f..9e69ffa60ad6f 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -8,6 +8,7 @@ import { } from "../agents/auth-profiles.js"; import { clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, type OpenClawConfig, } from "../config/config.js"; @@ -34,7 +35,18 @@ export type PreparedSecretsRuntimeSnapshot = { warnings: SecretResolverWarning[]; }; +type SecretsRuntimeRefreshContext = { + env: Record; + explicitAgentDirs: string[] | null; + loadAuthStore: (agentDir?: string) => AuthProfileStore; +}; + let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null; +let activeRefreshContext: SecretsRuntimeRefreshContext | null = null; +const preparedSnapshotRefreshContext = new WeakMap< + PreparedSecretsRuntimeSnapshot, + SecretsRuntimeRefreshContext +>(); function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot { return { @@ -48,6 +60,22 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret }; } +function cloneRefreshContext(context: SecretsRuntimeRefreshContext): SecretsRuntimeRefreshContext { + return { + env: { ...context.env }, + explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null, + loadAuthStore: context.loadAuthStore, + }; +} + +function clearActiveSecretsRuntimeState(): void { + activeSnapshot = null; + activeRefreshContext = null; + setRuntimeConfigSnapshotRefreshHandler(null); + clearRuntimeConfigSnapshot(); + clearRuntimeAuthProfileStoreSnapshots(); +} + function collectCandidateAgentDirs(config: OpenClawConfig): string[] { const dirs = new Set(); dirs.add(resolveUserPath(resolveOpenClawAgentDir())); @@ -57,6 +85,17 @@ function collectCandidateAgentDirs(config: OpenClawConfig): string[] { return [...dirs]; } +function resolveRefreshAgentDirs( + config: OpenClawConfig, + context: SecretsRuntimeRefreshContext, +): string[] { + const configDerived = collectCandidateAgentDirs(config); + if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) { + return configDerived; + } + return [...new Set([...context.explicitAgentDirs, ...configDerived])]; +} + export async function prepareSecretsRuntimeSnapshot(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -104,23 +143,61 @@ export async function prepareSecretsRuntimeSnapshot(params: { }); } - return { + const snapshot = { sourceConfig, config: resolvedConfig, authStores, warnings: context.warnings, }; + preparedSnapshotRefreshContext.set(snapshot, { + env: { ...(params.env ?? process.env) } as Record, + explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null, + loadAuthStore, + }); + return snapshot; } export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void { const next = cloneSnapshot(snapshot); + const refreshContext = + preparedSnapshotRefreshContext.get(snapshot) ?? + activeRefreshContext ?? + ({ + env: { ...process.env } as Record, + explicitAgentDirs: null, + loadAuthStore: loadAuthProfileStoreForSecretsRuntime, + } satisfies SecretsRuntimeRefreshContext); setRuntimeConfigSnapshot(next.config, next.sourceConfig); replaceRuntimeAuthProfileStoreSnapshots(next.authStores); activeSnapshot = next; + activeRefreshContext = cloneRefreshContext(refreshContext); + setRuntimeConfigSnapshotRefreshHandler({ + refresh: async ({ sourceConfig }) => { + if (!activeSnapshot || !activeRefreshContext) { + return false; + } + const refreshed = await prepareSecretsRuntimeSnapshot({ + config: sourceConfig, + env: activeRefreshContext.env, + agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext), + loadAuthStore: activeRefreshContext.loadAuthStore, + }); + activateSecretsRuntimeSnapshot(refreshed); + return true; + }, + clearOnRefreshFailure: clearActiveSecretsRuntimeState, + }); } export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null { - return activeSnapshot ? cloneSnapshot(activeSnapshot) : null; + if (!activeSnapshot) { + return null; + } + const snapshot = cloneSnapshot(activeSnapshot); + if (activeRefreshContext) { + preparedSnapshotRefreshContext.set(snapshot, cloneRefreshContext(activeRefreshContext)); + } + return snapshot; } export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: { @@ -155,7 +232,5 @@ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: { } export function clearSecretsRuntimeSnapshot(): void { - activeSnapshot = null; - clearRuntimeConfigSnapshot(); - clearRuntimeAuthProfileStoreSnapshots(); + clearActiveSecretsRuntimeState(); } From 141738f717d19cc800508c39a2e73675b2bc8ec5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:25:29 +0000 Subject: [PATCH 191/311] refactor: harden browser runtime profile handling --- extensions/bluebubbles/src/config-schema.ts | 5 +- extensions/bluebubbles/src/runtime.ts | 2 +- extensions/discord/src/channel.ts | 2 +- extensions/discord/src/runtime.ts | 2 +- extensions/feishu/src/runtime.ts | 2 +- extensions/googlechat/src/channel.ts | 2 +- extensions/googlechat/src/runtime.ts | 2 +- extensions/imessage/src/runtime.ts | 2 +- extensions/irc/src/runtime.ts | 2 +- extensions/line/src/runtime.ts | 2 +- extensions/matrix/src/runtime.ts | 2 +- extensions/mattermost/src/runtime.ts | 2 +- extensions/msteams/src/runtime.ts | 2 +- extensions/nextcloud-talk/src/runtime.ts | 2 +- extensions/nostr/src/runtime.ts | 2 +- extensions/signal/src/runtime.ts | 2 +- extensions/slack/src/channel.ts | 2 +- extensions/slack/src/runtime.ts | 2 +- extensions/synology-chat/src/runtime.ts | 2 +- extensions/telegram/src/channel.ts | 2 +- extensions/telegram/src/runtime.ts | 2 +- extensions/tlon/src/runtime.ts | 2 +- extensions/twitch/src/runtime.ts | 2 +- extensions/whatsapp/src/runtime.ts | 2 +- extensions/zalo/src/config-schema.ts | 5 +- extensions/zalo/src/runtime.ts | 2 +- extensions/zalouser/src/config-schema.ts | 5 +- extensions/zalouser/src/runtime.ts | 2 +- src/browser/client.ts | 2 + src/browser/control-service.ts | 29 +-- src/browser/errors.ts | 82 ++++++++ src/browser/profile-capabilities.ts | 100 ++++++++++ src/browser/profiles-service.test.ts | 31 +++ src/browser/profiles-service.ts | 42 ++++- src/browser/pw-session.ts | 5 +- src/browser/resolved-config-refresh.ts | 36 ++++ src/browser/routes/agent.shared.ts | 5 + .../routes/agent.snapshot.plan.test.ts | 33 ++++ src/browser/routes/agent.snapshot.plan.ts | 97 ++++++++++ src/browser/routes/agent.snapshot.test.ts | 24 ++- src/browser/routes/agent.snapshot.ts | 177 ++++++++---------- src/browser/routes/basic.ts | 36 ++-- src/browser/routes/tabs.ts | 11 +- src/browser/runtime-lifecycle.ts | 60 ++++++ src/browser/server-context.availability.ts | 53 +++++- ...server-context.hot-reload-profiles.test.ts | 41 +++- src/browser/server-context.reset.ts | 9 +- src/browser/server-context.selection.ts | 26 ++- src/browser/server-context.tab-ops.ts | 12 +- src/browser/server-context.ts | 27 ++- src/browser/server-context.types.ts | 6 + ...s-open-profile-unknown-returns-404.test.ts | 13 ++ src/browser/server.ts | 38 +--- 53 files changed, 790 insertions(+), 270 deletions(-) create mode 100644 src/browser/errors.ts create mode 100644 src/browser/profile-capabilities.ts create mode 100644 src/browser/routes/agent.snapshot.plan.test.ts create mode 100644 src/browser/routes/agent.snapshot.plan.ts create mode 100644 src/browser/runtime-lifecycle.ts diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 94a0661afb764..32e239d3f4520 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,5 +1,8 @@ -import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles"; +import { + AllowFromEntrySchema, + buildCatchallMultiAccountChannelSchema, +} from "openclaw/plugin-sdk/compat"; import { z } from "zod"; import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index e1c0254e1c08e..ee91445d69bfe 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -1,5 +1,5 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; const runtimeStore = createPluginRuntimeStore("BlueBubbles runtime not initialized"); type LegacyRuntimeLogShape = { log?: (message: string) => void }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 23a4a2ffae8c9..c6852a634693c 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 9a23266edda5a..2cc0074f4573b 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/discord"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index c1a4b65c50a50..2e174a59320af 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/feishu"; const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } = diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index f0c5dace9f04f..2be9ae3335b24 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, buildOpenGroupPolicyConfigureRouteAllowlistWarning, diff --git a/extensions/googlechat/src/runtime.ts b/extensions/googlechat/src/runtime.ts index 2276eb7dcfa52..44731cba8ea1e 100644 --- a/extensions/googlechat/src/runtime.ts +++ b/extensions/googlechat/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat"; const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } = diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index a4b2f1a98de3f..7bc726cb0895b 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/imessage"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = diff --git a/extensions/irc/src/runtime.ts b/extensions/irc/src/runtime.ts index b5597236b7a0c..e1d60a14652b2 100644 --- a/extensions/irc/src/runtime.ts +++ b/extensions/irc/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/irc"; const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } = diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 38ed57e7875b3..57307cbe64e26 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/line"; const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } = diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index 90fe7d1f8e9ba..eefce7b910a93 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index 8fe131f233531..1f112c8361f59 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost"; const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts index 04444a29fc1db..f9d1dec571496 100644 --- a/extensions/msteams/src/runtime.ts +++ b/extensions/msteams/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/msteams"; const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } = diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index d4870a7483920..4e539eb36870c 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk"; const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index 1063bd8d6d305..347079d9750f3 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index fd6c5fbdae6cc..480c174ab2681 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/signal"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 1fdf4018f2823..570ef20ffa1d5 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 9ba83fcb4c820..7961547004ce1 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/slack"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index 6abb71d8188dc..2f9b401192c9d 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d8879ab5858c1..0f4721a4d621d 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { collectAllowlistProviderGroupPolicyWarnings, buildAccountScopedDmSecurityPolicy, diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index 4effcb7b5bf69..8923cdd3e8d53 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/telegram"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index 1551ea38f3f3e..8df350889129b 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/tlon"; const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index f82e4313f81f2..18deeb40c078e 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/twitch"; const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index c5044db6a2906..13ace8243db25 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index f2e5c5803e76f..5f4886cdaf948 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,4 +1,7 @@ -import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; +import { + AllowFromEntrySchema, + buildCatchallMultiAccountChannelSchema, +} from "openclaw/plugin-sdk/compat"; import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo"; import { z } from "zod"; import { buildSecretInputSchema } from "./secret-input.js"; diff --git a/extensions/zalo/src/runtime.ts b/extensions/zalo/src/runtime.ts index 7454204391360..10f417b3c7f62 100644 --- a/extensions/zalo/src/runtime.ts +++ b/extensions/zalo/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalo"; const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index dd0f9c51fbe18..e5cb64d012e01 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,4 +1,7 @@ -import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk"; +import { + AllowFromEntrySchema, + buildCatchallMultiAccountChannelSchema, +} from "openclaw/plugin-sdk/compat"; import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser"; import { z } from "zod"; diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts index 473df2b8fbef5..44cf09edbc70b 100644 --- a/extensions/zalouser/src/runtime.ts +++ b/extensions/zalouser/src/runtime.ts @@ -1,4 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser"; const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } = diff --git a/src/browser/client.ts b/src/browser/client.ts index 76b799bde64e9..953c9efcd11ce 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -30,6 +30,8 @@ export type ProfileStatus = { tabCount: number; isDefault: boolean; isRemote: boolean; + missingFromConfig?: boolean; + reconcileReason?: string | null; }; export type BrowserResetProfileResult = { diff --git a/src/browser/control-service.ts b/src/browser/control-service.ts index 031bc5e00cd62..48dc08beb3016 100644 --- a/src/browser/control-service.ts +++ b/src/browser/control-service.ts @@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveBrowserConfig } from "./config.js"; import { ensureBrowserControlAuth } from "./control-auth.js"; +import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; -import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; let state: BrowserServerState | null = null; const log = createSubsystemLogger("browser"); @@ -39,14 +39,9 @@ export async function startBrowserControlServiceFromConfig(): Promise logService.warn(message), }); @@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise { const current = state; - if (!current) { - return; - } - - await stopKnownBrowserProfiles({ + await stopBrowserRuntime({ + current, getState: () => state, + clearState: () => { + state = null; + }, onWarn: (message) => logService.warn(message), }); - - state = null; - - // Optional: Playwright is not always available (e.g. embedded gateway builds). - try { - const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(); - } catch { - // ignore - } } diff --git a/src/browser/errors.ts b/src/browser/errors.ts new file mode 100644 index 0000000000000..11a9bcec646f5 --- /dev/null +++ b/src/browser/errors.ts @@ -0,0 +1,82 @@ +import { SsrFBlockedError } from "../infra/net/ssrf.js"; +import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; + +export class BrowserError extends Error { + status: number; + + constructor(message: string, status = 500, options?: ErrorOptions) { + super(message, options); + this.name = new.target.name; + this.status = status; + } +} + +export class BrowserValidationError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 400, options); + } +} + +export class BrowserConfigurationError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 400, options); + } +} + +export class BrowserTargetAmbiguousError extends BrowserError { + constructor(message = "ambiguous target id prefix", options?: ErrorOptions) { + super(message, 409, options); + } +} + +export class BrowserTabNotFoundError extends BrowserError { + constructor(message = "tab not found", options?: ErrorOptions) { + super(message, 404, options); + } +} + +export class BrowserProfileNotFoundError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 404, options); + } +} + +export class BrowserConflictError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 409, options); + } +} + +export class BrowserResetUnsupportedError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 400, options); + } +} + +export class BrowserProfileUnavailableError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 409, options); + } +} + +export class BrowserResourceExhaustedError extends BrowserError { + constructor(message: string, options?: ErrorOptions) { + super(message, 507, options); + } +} + +export function toBrowserErrorResponse(err: unknown): { + status: number; + message: string; +} | null { + if (err instanceof BrowserError) { + return { status: err.status, message: err.message }; + } + if (err instanceof SsrFBlockedError) { + return { status: 400, message: err.message }; + } + if (err instanceof InvalidBrowserNavigationUrlError) { + return { status: 400, message: err.message }; + } + return null; +} diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts new file mode 100644 index 0000000000000..07a70ba00c40f --- /dev/null +++ b/src/browser/profile-capabilities.ts @@ -0,0 +1,100 @@ +import type { ResolvedBrowserProfile } from "./config.js"; + +export type BrowserProfileMode = "local-managed" | "local-extension-relay" | "remote-cdp"; + +export type BrowserProfileCapabilities = { + mode: BrowserProfileMode; + isRemote: boolean; + requiresRelay: boolean; + requiresAttachedTab: boolean; + usesPersistentPlaywright: boolean; + supportsPerTabWs: boolean; + supportsJsonTabEndpoints: boolean; + supportsReset: boolean; + supportsManagedTabLimit: boolean; +}; + +export function getBrowserProfileCapabilities( + profile: ResolvedBrowserProfile, +): BrowserProfileCapabilities { + if (profile.driver === "extension") { + return { + mode: "local-extension-relay", + isRemote: false, + requiresRelay: true, + requiresAttachedTab: true, + usesPersistentPlaywright: false, + supportsPerTabWs: false, + supportsJsonTabEndpoints: true, + supportsReset: true, + supportsManagedTabLimit: false, + }; + } + + if (!profile.cdpIsLoopback) { + return { + mode: "remote-cdp", + isRemote: true, + requiresRelay: false, + requiresAttachedTab: false, + usesPersistentPlaywright: true, + supportsPerTabWs: false, + supportsJsonTabEndpoints: false, + supportsReset: false, + supportsManagedTabLimit: false, + }; + } + + return { + mode: "local-managed", + isRemote: false, + requiresRelay: false, + requiresAttachedTab: false, + usesPersistentPlaywright: false, + supportsPerTabWs: true, + supportsJsonTabEndpoints: true, + supportsReset: true, + supportsManagedTabLimit: true, + }; +} + +export function resolveDefaultSnapshotFormat(params: { + profile: ResolvedBrowserProfile; + hasPlaywright: boolean; + explicitFormat?: "ai" | "aria"; + mode?: "efficient"; +}): "ai" | "aria" { + if (params.explicitFormat) { + return params.explicitFormat; + } + if (params.mode === "efficient") { + return "ai"; + } + + const capabilities = getBrowserProfileCapabilities(params.profile); + if (capabilities.mode === "local-extension-relay") { + return "aria"; + } + + return params.hasPlaywright ? "ai" : "aria"; +} + +export function shouldUsePlaywrightForScreenshot(params: { + profile: ResolvedBrowserProfile; + wsUrl?: string; + ref?: string; + element?: string; +}): boolean { + const capabilities = getBrowserProfileCapabilities(params.profile); + return ( + capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element) + ); +} + +export function shouldUsePlaywrightForAriaSnapshot(params: { + profile: ResolvedBrowserProfile; + wsUrl?: string; +}): boolean { + const capabilities = getBrowserProfileCapabilities(params.profile); + return capabilities.requiresRelay || !params.wsUrl; +} diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index 38ed6e3c03cff..3dc714d33f365 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -132,6 +132,37 @@ describe("BrowserProfilesService", () => { ); }); + it("rejects driver=extension with non-loopback cdpUrl", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const service = createBrowserProfilesService(ctx); + + await expect( + service.createProfile({ + name: "chrome-remote", + driver: "extension", + cdpUrl: "http://10.0.0.42:9222", + }), + ).rejects.toThrow(/loopback cdpUrl host/i); + }); + + it("rejects driver=extension without an explicit cdpUrl", async () => { + const resolved = resolveBrowserConfig({}); + const { ctx } = createCtx(resolved); + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const service = createBrowserProfilesService(ctx); + + await expect( + service.createProfile({ + name: "chrome-extension", + driver: "extension", + }), + ).rejects.toThrow(/requires an explicit loopback cdpUrl/i); + }); + it("deletes remote profiles without stopping or removing local data", async () => { const resolved = resolveBrowserConfig({ profiles: { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 5625cc924dbe2..962c6408522f6 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,9 +3,16 @@ import path from "node:path"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; +import { isLoopbackHost } from "../gateway/net.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js"; +import { + BrowserConflictError, + BrowserProfileNotFoundError, + BrowserResourceExhaustedError, + BrowserValidationError, +} from "./errors.js"; import { allocateCdpPort, allocateColor, @@ -75,19 +82,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const driver = params.driver === "extension" ? "extension" : undefined; if (!isValidProfileName(name)) { - throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only"); + throw new BrowserValidationError( + "invalid profile name: use lowercase letters, numbers, and hyphens only", + ); } const state = ctx.state(); const resolvedProfiles = state.resolved.profiles; if (name in resolvedProfiles) { - throw new Error(`profile "${name}" already exists`); + throw new BrowserConflictError(`profile "${name}" already exists`); } const cfg = loadConfig(); const rawProfiles = cfg.browser?.profiles ?? {}; if (name in rawProfiles) { - throw new Error(`profile "${name}" already exists`); + throw new BrowserConflictError(`profile "${name}" already exists`); } const usedColors = getUsedColors(resolvedProfiles); @@ -97,17 +106,32 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { let profileConfig: BrowserProfileConfig; if (rawCdpUrl) { const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl"); + if (driver === "extension") { + if (!isLoopbackHost(parsed.parsed.hostname)) { + throw new BrowserValidationError( + `driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`, + ); + } + if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") { + throw new BrowserValidationError( + `driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`, + ); + } + } profileConfig = { cdpUrl: parsed.normalized, ...(driver ? { driver } : {}), color: profileColor, }; } else { + if (driver === "extension") { + throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl"); + } const usedPorts = getUsedPorts(resolvedProfiles); const range = cdpPortRange(state.resolved); const cdpPort = allocateCdpPort(usedPorts, range); if (cdpPort === null) { - throw new Error("no available CDP ports in range"); + throw new BrowserResourceExhaustedError("no available CDP ports in range"); } profileConfig = { cdpPort, @@ -132,7 +156,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { state.resolved.profiles[name] = profileConfig; const resolved = resolveProfile(state.resolved, name); if (!resolved) { - throw new Error(`profile "${name}" not found after creation`); + throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`); } return { @@ -148,21 +172,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { const deleteProfile = async (nameRaw: string): Promise => { const name = nameRaw.trim(); if (!name) { - throw new Error("profile name is required"); + throw new BrowserValidationError("profile name is required"); } if (!isValidProfileName(name)) { - throw new Error("invalid profile name"); + throw new BrowserValidationError("invalid profile name"); } const cfg = loadConfig(); const profiles = cfg.browser?.profiles ?? {}; if (!(name in profiles)) { - throw new Error(`profile "${name}" not found`); + throw new BrowserProfileNotFoundError(`profile "${name}" not found`); } const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME; if (name === defaultProfile) { - throw new Error( + throw new BrowserValidationError( `cannot delete the default profile "${name}"; change browser.defaultProfile first`, ); } diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 53f9c24114248..a058ba07791b4 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -19,6 +19,7 @@ import { } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { getChromeWebSocketUrl } from "./chrome.js"; +import { BrowserTabNotFoundError } from "./errors.js"; import { assertBrowserNavigationAllowed, assertBrowserNavigationResultAllowed, @@ -495,7 +496,7 @@ async function resolvePageByTargetIdOrThrow(opts: { const { browser } = await connectBrowser(opts.cdpUrl); const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!page) { - throw new Error("tab not found"); + throw new BrowserTabNotFoundError(); } return page; } @@ -521,7 +522,7 @@ export async function getPageForTargetId(opts: { if (pages.length === 1) { return first; } - throw new Error("tab not found"); + throw new BrowserTabNotFoundError(); } return found; } diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 721049036d4d7..fe934069a80ce 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -2,6 +2,29 @@ import { createConfigIO, loadConfig } from "../config/config.js"; import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js"; import type { BrowserServerState } from "./server-context.types.js"; +function changedProfileInvariants( + current: ResolvedBrowserProfile, + next: ResolvedBrowserProfile, +): string[] { + const changed: string[] = []; + if (current.cdpUrl !== next.cdpUrl) { + changed.push("cdpUrl"); + } + if (current.cdpPort !== next.cdpPort) { + changed.push("cdpPort"); + } + if (current.driver !== next.driver) { + changed.push("driver"); + } + if (current.attachOnly !== next.attachOnly) { + changed.push("attachOnly"); + } + if (current.cdpIsLoopback !== next.cdpIsLoopback) { + changed.push("cdpIsLoopback"); + } + return changed; +} + function applyResolvedConfig( current: BrowserServerState, freshResolved: BrowserServerState["resolved"], @@ -10,9 +33,22 @@ function applyResolvedConfig( for (const [name, runtime] of current.profiles) { const nextProfile = resolveProfile(freshResolved, name); if (nextProfile) { + const changed = changedProfileInvariants(runtime.profile, nextProfile); + if (changed.length > 0) { + runtime.reconcile = { + previousProfile: runtime.profile, + reason: `profile invariants changed: ${changed.join(", ")}`, + }; + runtime.lastTargetId = null; + } runtime.profile = nextProfile; continue; } + runtime.reconcile = { + previousProfile: runtime.profile, + reason: "profile removed from config", + }; + runtime.lastTargetId = null; if (!runtime.running) { current.profiles.delete(name); } diff --git a/src/browser/routes/agent.shared.ts b/src/browser/routes/agent.shared.ts index aee566965254c..cc82e00d00437 100644 --- a/src/browser/routes/agent.shared.ts +++ b/src/browser/routes/agent.shared.ts @@ -1,3 +1,4 @@ +import { toBrowserErrorResponse } from "../errors.js"; import type { PwAiModule } from "../pw-ai-module.js"; import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; @@ -37,6 +38,10 @@ export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, if (mapped) { return jsonError(res, mapped.status, mapped.message); } + const browserMapped = toBrowserErrorResponse(err); + if (browserMapped) { + return jsonError(res, browserMapped.status, browserMapped.message); + } jsonError(res, 500, String(err)); } diff --git a/src/browser/routes/agent.snapshot.plan.test.ts b/src/browser/routes/agent.snapshot.plan.test.ts new file mode 100644 index 0000000000000..493fbcdfbad8e --- /dev/null +++ b/src/browser/routes/agent.snapshot.plan.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { resolveBrowserConfig, resolveProfile } from "../config.js"; +import { resolveSnapshotPlan } from "./agent.snapshot.plan.js"; + +describe("resolveSnapshotPlan", () => { + it("defaults chrome extension relay snapshots to aria when format is omitted", () => { + const resolved = resolveBrowserConfig({}); + const profile = resolveProfile(resolved, "chrome"); + expect(profile).toBeTruthy(); + + const plan = resolveSnapshotPlan({ + profile: profile as NonNullable, + query: {}, + hasPlaywright: true, + }); + + expect(plan.format).toBe("aria"); + }); + + it("keeps ai snapshots for managed browsers when Playwright is available", () => { + const resolved = resolveBrowserConfig({}); + const profile = resolveProfile(resolved, "openclaw"); + expect(profile).toBeTruthy(); + + const plan = resolveSnapshotPlan({ + profile: profile as NonNullable, + query: {}, + hasPlaywright: true, + }); + + expect(plan.format).toBe("ai"); + }); +}); diff --git a/src/browser/routes/agent.snapshot.plan.ts b/src/browser/routes/agent.snapshot.plan.ts new file mode 100644 index 0000000000000..6c913400d90f5 --- /dev/null +++ b/src/browser/routes/agent.snapshot.plan.ts @@ -0,0 +1,97 @@ +import type { ResolvedBrowserProfile } from "../config.js"; +import { + DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, + DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, + DEFAULT_AI_SNAPSHOT_MAX_CHARS, +} from "../constants.js"; +import { + resolveDefaultSnapshotFormat, + shouldUsePlaywrightForAriaSnapshot, + shouldUsePlaywrightForScreenshot, +} from "../profile-capabilities.js"; +import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; + +export type BrowserSnapshotPlan = { + format: "ai" | "aria"; + mode?: "efficient"; + labels?: boolean; + limit?: number; + resolvedMaxChars?: number; + interactive?: boolean; + compact?: boolean; + depth?: number; + refsMode?: "aria" | "role"; + selectorValue?: string; + frameSelectorValue?: string; + wantsRoleSnapshot: boolean; +}; + +export function resolveSnapshotPlan(params: { + profile: ResolvedBrowserProfile; + query: Record; + hasPlaywright: boolean; +}): BrowserSnapshotPlan { + const mode = params.query.mode === "efficient" ? "efficient" : undefined; + const labels = toBoolean(params.query.labels) ?? undefined; + const explicitFormat = + params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined; + const format = resolveDefaultSnapshotFormat({ + profile: params.profile, + hasPlaywright: params.hasPlaywright, + explicitFormat, + mode, + }); + const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined; + const hasMaxChars = Object.hasOwn(params.query, "maxChars"); + const maxCharsRaw = + typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined; + const limit = Number.isFinite(limitRaw) ? limitRaw : undefined; + const maxChars = + typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0 + ? Math.floor(maxCharsRaw) + : undefined; + const resolvedMaxChars = + format === "ai" + ? hasMaxChars + ? maxChars + : mode === "efficient" + ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS + : DEFAULT_AI_SNAPSHOT_MAX_CHARS + : undefined; + const interactiveRaw = toBoolean(params.query.interactive); + const compactRaw = toBoolean(params.query.compact); + const depthRaw = toNumber(params.query.depth); + const refsModeRaw = toStringOrEmpty(params.query.refs).trim(); + const refsMode: "aria" | "role" | undefined = + refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined; + const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined); + const compact = compactRaw ?? (mode === "efficient" ? true : undefined); + const depth = + depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined); + const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined; + const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined; + + return { + format, + mode, + labels, + limit, + resolvedMaxChars, + interactive, + compact, + depth, + refsMode, + selectorValue, + frameSelectorValue, + wantsRoleSnapshot: + labels === true || + mode === "efficient" || + interactive === true || + compact === true || + depth !== undefined || + Boolean(selectorValue) || + Boolean(frameSelectorValue), + }; +} + +export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot }; diff --git a/src/browser/routes/agent.snapshot.test.ts b/src/browser/routes/agent.snapshot.test.ts index 77b802bdf7dba..b31ea1c3e7d0b 100644 --- a/src/browser/routes/agent.snapshot.test.ts +++ b/src/browser/routes/agent.snapshot.test.ts @@ -38,8 +38,8 @@ describe("resolveTargetIdAfterNavigate", () => { { targetId: "fresh-777", url: "https://example.com" }, ]), }); - // Both differ from old targetId; the first non-stale match wins. - expect(result).toBe("preexisting-000"); + // Ambiguous replacement; prefer staying on the old target rather than guessing wrong. + expect(result).toBe("old-123"); }); it("retries and resolves targetId when first listTabs has no URL match", async () => { @@ -114,4 +114,24 @@ describe("resolveTargetIdAfterNavigate", () => { }); expect(result).toBe("old-123"); }); + + it("keeps the old target when multiple replacement candidates still match after retry", async () => { + vi.useFakeTimers(); + + const result$ = resolveTargetIdAfterNavigate({ + oldTargetId: "old-123", + navigatedUrl: "https://example.com", + listTabs: staticListTabs([ + { targetId: "preexisting-000", url: "https://example.com" }, + { targetId: "fresh-777", url: "https://example.com" }, + ]), + }); + + await vi.advanceTimersByTimeAsync(800); + const result = await result$; + + expect(result).toBe("old-123"); + + vi.useRealTimers(); + }); }); diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 7739caa051e3d..c750cafe723e6 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -1,11 +1,6 @@ import path from "node:path"; import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; import { captureScreenshot, snapshotAria } from "../cdp.js"; -import { - DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, - DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, - DEFAULT_AI_SNAPSHOT_MAX_CHARS, -} from "../constants.js"; import { withBrowserNavigationPolicy } from "../navigation-guard.js"; import { DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, @@ -22,8 +17,13 @@ import { withPlaywrightRouteContext, withRouteTabContext, } from "./agent.shared.js"; +import { + resolveSnapshotPlan, + shouldUsePlaywrightForAriaSnapshot, + shouldUsePlaywrightForScreenshot, +} from "./agent.snapshot.plan.js"; import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js"; -import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; +import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js"; async function saveBrowserMediaResponse(params: { res: BrowserResponse; @@ -56,26 +56,28 @@ export async function resolveTargetIdAfterNavigate(opts: { }): Promise { let currentTargetId = opts.oldTargetId; try { - const refreshed = await opts.listTabs(); - if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) { - // Renderer swap: old target gone, resolve the replacement. - // Prefer a URL match whose targetId differs from the old one - // to avoid picking a pre-existing tab when multiple share the URL. - const byUrl = refreshed.filter((t) => t.url === opts.navigatedUrl); - const replaced = byUrl.find((t) => t.targetId !== opts.oldTargetId) ?? byUrl[0]; - if (replaced) { - currentTargetId = replaced.targetId; - } else { - await new Promise((r) => setTimeout(r, 800)); - const retried = await opts.listTabs(); - const match = - retried.find((t) => t.url === opts.navigatedUrl && t.targetId !== opts.oldTargetId) ?? - retried.find((t) => t.url === opts.navigatedUrl) ?? - (retried.length === 1 ? retried[0] : null); - if (match) { - currentTargetId = match.targetId; - } + const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => { + if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) { + return opts.oldTargetId; + } + const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl); + if (byUrl.length === 1) { + return byUrl[0]?.targetId ?? opts.oldTargetId; + } + const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId); + if (uniqueReplacement.length === 1) { + return uniqueReplacement[0]?.targetId ?? opts.oldTargetId; + } + if (tabs.length === 1) { + return tabs[0]?.targetId ?? opts.oldTargetId; } + return opts.oldTargetId; + }; + + currentTargetId = pickReplacement(await opts.listTabs()); + if (currentTargetId === opts.oldTargetId) { + await new Promise((r) => setTimeout(r, 800)); + currentTargetId = pickReplacement(await opts.listTabs()); } } catch { // Best-effort: fall back to pre-navigation targetId @@ -162,11 +164,12 @@ export function registerBrowserAgentSnapshotRoutes( targetId, run: async ({ profileCtx, tab, cdpUrl }) => { let buffer: Buffer; - const shouldUsePlaywright = - profileCtx.profile.driver === "extension" || - !tab.wsUrl || - Boolean(ref) || - Boolean(element); + const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({ + profile: profileCtx.profile, + wsUrl: tab.wsUrl, + ref, + element, + }); if (shouldUsePlaywright) { const pw = await requirePwAi(res, "screenshot"); if (!pw) { @@ -212,81 +215,45 @@ export function registerBrowserAgentSnapshotRoutes( return; } const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; - const mode = req.query.mode === "efficient" ? "efficient" : undefined; - const labels = toBoolean(req.query.labels) ?? undefined; - const explicitFormat = - req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined; - const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria"); - const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; - const hasMaxChars = Object.hasOwn(req.query, "maxChars"); - const maxCharsRaw = - typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : undefined; - const limit = Number.isFinite(limitRaw) ? limitRaw : undefined; - const maxChars = - typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0 - ? Math.floor(maxCharsRaw) - : undefined; - const resolvedMaxChars = - format === "ai" - ? hasMaxChars - ? maxChars - : mode === "efficient" - ? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS - : DEFAULT_AI_SNAPSHOT_MAX_CHARS - : undefined; - const interactiveRaw = toBoolean(req.query.interactive); - const compactRaw = toBoolean(req.query.compact); - const depthRaw = toNumber(req.query.depth); - const refsModeRaw = toStringOrEmpty(req.query.refs).trim(); - const refsMode: "aria" | "role" | undefined = - refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined; - const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined); - const compact = compactRaw ?? (mode === "efficient" ? true : undefined); - const depth = - depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined); - const selector = toStringOrEmpty(req.query.selector); - const frameSelector = toStringOrEmpty(req.query.frame); - const selectorValue = selector.trim() || undefined; - const frameSelectorValue = frameSelector.trim() || undefined; + const hasPlaywright = Boolean(await getPwAiModule()); + const plan = resolveSnapshotPlan({ + profile: profileCtx.profile, + query: req.query, + hasPlaywright, + }); try { const tab = await profileCtx.ensureTabAvailable(targetId || undefined); - if ((labels || mode === "efficient") && format === "aria") { + if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") { return jsonError(res, 400, "labels/mode=efficient require format=ai"); } - if (format === "ai") { + if (plan.format === "ai") { const pw = await requirePwAi(res, "ai snapshot"); if (!pw) { return; } - const wantsRoleSnapshot = - labels === true || - mode === "efficient" || - interactive === true || - compact === true || - depth !== undefined || - Boolean(selectorValue) || - Boolean(frameSelectorValue); const roleSnapshotArgs = { cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, - selector: selectorValue, - frameSelector: frameSelectorValue, - refsMode, + selector: plan.selectorValue, + frameSelector: plan.frameSelectorValue, + refsMode: plan.refsMode, options: { - interactive: interactive ?? undefined, - compact: compact ?? undefined, - maxDepth: depth ?? undefined, + interactive: plan.interactive ?? undefined, + compact: plan.compact ?? undefined, + maxDepth: plan.depth ?? undefined, }, }; - const snap = wantsRoleSnapshot + const snap = plan.wantsRoleSnapshot ? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs) : await pw .snapshotAiViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, - ...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), + ...(typeof plan.resolvedMaxChars === "number" + ? { maxChars: plan.resolvedMaxChars } + : {}), }) .catch(async (err) => { // Public-API fallback when Playwright's private _snapshotForAI is missing. @@ -295,7 +262,7 @@ export function registerBrowserAgentSnapshotRoutes( } throw err; }); - if (labels) { + if (plan.labels) { const labeled = await pw.screenshotWithLabelsViaPlaywright({ cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, @@ -316,7 +283,7 @@ export function registerBrowserAgentSnapshotRoutes( const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png"; return res.json({ ok: true, - format, + format: plan.format, targetId: tab.targetId, url: tab.url, labels: true, @@ -330,30 +297,32 @@ export function registerBrowserAgentSnapshotRoutes( return res.json({ ok: true, - format, + format: plan.format, targetId: tab.targetId, url: tab.url, ...snap, }); } - const snap = - profileCtx.profile.driver === "extension" || !tab.wsUrl - ? (() => { - // Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session. - // Also covers cases where wsUrl is missing/unusable. - return requirePwAi(res, "aria snapshot").then(async (pw) => { - if (!pw) { - return null; - } - return await pw.snapshotAriaViaPlaywright({ - cdpUrl: profileCtx.profile.cdpUrl, - targetId: tab.targetId, - limit, - }); + const snap = shouldUsePlaywrightForAriaSnapshot({ + profile: profileCtx.profile, + wsUrl: tab.wsUrl, + }) + ? (() => { + // Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session. + // Also covers cases where wsUrl is missing/unusable. + return requirePwAi(res, "aria snapshot").then(async (pw) => { + if (!pw) { + return null; + } + return await pw.snapshotAriaViaPlaywright({ + cdpUrl: profileCtx.profile.cdpUrl, + targetId: tab.targetId, + limit: plan.limit, }); - })() - : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit }); + }); + })() + : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit }); const resolved = await Promise.resolve(snap); if (!resolved) { @@ -361,7 +330,7 @@ export function registerBrowserAgentSnapshotRoutes( } return res.json({ ok: true, - format, + format: plan.format, targetId: tab.targetId, url: tab.url, ...resolved, diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index 074e7ea285dbb..5f32c86729b65 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -1,4 +1,5 @@ import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js"; +import { toBrowserErrorResponse } from "../errors.js"; import { createBrowserProfilesService } from "../profiles-service.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import { resolveProfileContext } from "./agent.shared.js"; @@ -18,6 +19,10 @@ async function withBasicProfileRoute(params: { try { await params.run(profileCtx); } catch (err) { + const mapped = toBrowserErrorResponse(err); + if (mapped) { + return jsonError(params.res, mapped.status, mapped.message); + } jsonError(params.res, 500, String(err)); } } @@ -157,20 +162,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow }); res.json(result); } catch (err) { - const msg = String(err); - if (msg.includes("already exists")) { - return jsonError(res, 409, msg); - } - if (msg.includes("invalid profile name")) { - return jsonError(res, 400, msg); - } - if (msg.includes("no available CDP ports")) { - return jsonError(res, 507, msg); - } - if (msg.includes("cdpUrl")) { - return jsonError(res, 400, msg); + const mapped = toBrowserErrorResponse(err); + if (mapped) { + return jsonError(res, mapped.status, mapped.message); } - jsonError(res, 500, msg); + jsonError(res, 500, String(err)); } }); @@ -186,17 +182,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow const result = await service.deleteProfile(name); res.json(result); } catch (err) { - const msg = String(err); - if (msg.includes("invalid profile name")) { - return jsonError(res, 400, msg); - } - if (msg.includes("default profile")) { - return jsonError(res, 400, msg); + const mapped = toBrowserErrorResponse(err); + if (mapped) { + return jsonError(res, mapped.status, mapped.message); } - if (msg.includes("not found")) { - return jsonError(res, 404, msg); - } - jsonError(res, 500, msg); + jsonError(res, 500, String(err)); } }); } diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts index 89531b22f9566..87cb36c562c00 100644 --- a/src/browser/routes/tabs.ts +++ b/src/browser/routes/tabs.ts @@ -1,3 +1,4 @@ +import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js"; @@ -50,7 +51,11 @@ async function withTabsProfileRoute(params: { async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) { if (!(await profileCtx.isReachable(300))) { - jsonError(res, 409, "browser not running"); + jsonError( + res, + new BrowserProfileUnavailableError("browser not running").status, + "browser not running", + ); return false; } return true; @@ -191,7 +196,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse const tabs = await profileCtx.listTabs(); const target = resolveIndexedTab(tabs, index); if (!target) { - return jsonError(res, 404, "tab not found"); + throw new BrowserTabNotFoundError(); } await profileCtx.closeTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); @@ -204,7 +209,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse const tabs = await profileCtx.listTabs(); const target = tabs[index]; if (!target) { - return jsonError(res, 404, "tab not found"); + throw new BrowserTabNotFoundError(); } await profileCtx.focusTab(target.targetId); return res.json({ ok: true, targetId: target.targetId }); diff --git a/src/browser/runtime-lifecycle.ts b/src/browser/runtime-lifecycle.ts new file mode 100644 index 0000000000000..7b181faea6ec7 --- /dev/null +++ b/src/browser/runtime-lifecycle.ts @@ -0,0 +1,60 @@ +import type { Server } from "node:http"; +import { isPwAiLoaded } from "./pw-ai-state.js"; +import type { BrowserServerState } from "./server-context.js"; +import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; + +export async function createBrowserRuntimeState(params: { + resolved: BrowserServerState["resolved"]; + port: number; + server?: Server | null; + onWarn: (message: string) => void; +}): Promise { + const state: BrowserServerState = { + server: params.server ?? null, + port: params.port, + resolved: params.resolved, + profiles: new Map(), + }; + + await ensureExtensionRelayForProfiles({ + resolved: params.resolved, + onWarn: params.onWarn, + }); + + return state; +} + +export async function stopBrowserRuntime(params: { + current: BrowserServerState | null; + getState: () => BrowserServerState | null; + clearState: () => void; + closeServer?: boolean; + onWarn: (message: string) => void; +}): Promise { + if (!params.current) { + return; + } + + await stopKnownBrowserProfiles({ + getState: params.getState, + onWarn: params.onWarn, + }); + + if (params.closeServer && params.current.server) { + await new Promise((resolve) => { + params.current?.server?.close(() => resolve()); + }); + } + + params.clearState(); + + if (!isPwAiLoaded()) { + return; + } + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(); + } catch { + // ignore + } +} diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index 07772c6b5981d..3b00ff99dffb0 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -10,10 +10,12 @@ import { stopOpenClawChrome, } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js"; import { ensureChromeExtensionRelayServer, stopChromeExtensionRelayServer, } from "./extension-relay.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, @@ -48,6 +50,7 @@ export function createProfileAvailability({ getProfileState, setProfileRunning, }: AvailabilityDeps): AvailabilityOps { + const capabilities = getBrowserProfileCapabilities(profile); const resolveTimeouts = (timeoutMs: number | undefined) => resolveCdpReachabilityTimeouts({ profileIsLoopback: profile.cdpIsLoopback, @@ -80,6 +83,38 @@ export function createProfileAvailability({ }); }; + const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise => { + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); + } catch { + // ignore + } + }; + + const reconcileProfileRuntime = async (): Promise => { + const profileState = getProfileState(); + const reconcile = profileState.reconcile; + if (!reconcile) { + return; + } + profileState.reconcile = null; + profileState.lastTargetId = null; + + const previousProfile = reconcile.previousProfile; + if (profileState.running) { + await stopOpenClawChrome(profileState.running).catch(() => {}); + setProfileRunning(null); + } + if (previousProfile.driver === "extension") { + await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false); + } + await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl); + if (previousProfile.cdpUrl !== profile.cdpUrl) { + await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl); + } + }; + const waitForCdpReadyAfterLaunch = async (): Promise => { // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. // If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port. @@ -102,15 +137,16 @@ export function createProfileAvailability({ }; const ensureBrowserAvailable = async (): Promise => { + await reconcileProfileRuntime(); const current = state(); - const remoteCdp = !profile.cdpIsLoopback; + const remoteCdp = capabilities.isRemote; const attachOnly = profile.attachOnly; - const isExtension = profile.driver === "extension"; + const isExtension = capabilities.requiresRelay; const profileState = getProfileState(); const httpReachable = await isHttpReachable(); if (isExtension && remoteCdp) { - throw new Error( + throw new BrowserConfigurationError( `Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`, ); } @@ -122,7 +158,7 @@ export function createProfileAvailability({ bindHost: current.resolved.relayBindHost, }); if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { - throw new Error( + throw new BrowserProfileUnavailableError( `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`, ); } @@ -140,7 +176,7 @@ export function createProfileAvailability({ } } if (attachOnly || remoteCdp) { - throw new Error( + throw new BrowserProfileUnavailableError( remoteCdp ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`, @@ -172,7 +208,7 @@ export function createProfileAvailability({ return; } } - throw new Error( + throw new BrowserProfileUnavailableError( remoteCdp ? `Remote CDP websocket for profile "${profile.name}" is not reachable.` : `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`, @@ -181,7 +217,7 @@ export function createProfileAvailability({ // HTTP responds but WebSocket fails - port in use by something else. if (!profileState.running) { - throw new Error( + throw new BrowserProfileUnavailableError( `Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` + `Run action=reset-profile profile=${profile.name} to kill the process.`, ); @@ -201,7 +237,8 @@ export function createProfileAvailability({ }; const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { - if (profile.driver === "extension") { + await reconcileProfileRuntime(); + if (capabilities.requiresRelay) { const stopped = await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl, }); diff --git a/src/browser/server-context.hot-reload-profiles.test.ts b/src/browser/server-context.hot-reload-profiles.test.ts index 7145dff517373..ec0c7e072aae2 100644 --- a/src/browser/server-context.hot-reload-profiles.test.ts +++ b/src/browser/server-context.hot-reload-profiles.test.ts @@ -1,9 +1,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveBrowserConfig } from "./config.js"; +import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload, } from "./resolved-config-refresh.js"; +import type { BrowserServerState } from "./server-context.types.js"; let cfgProfiles: Record = {}; @@ -166,4 +167,42 @@ describe("server-context hot-reload profiles", () => { }); expect(Object.keys(state.resolved.profiles)).toContain("desktop"); }); + + it("marks existing runtime state for reconcile when profile invariants change", async () => { + const cfg = loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const openclawProfile = resolveProfile(resolved, "openclaw"); + expect(openclawProfile).toBeTruthy(); + const state: BrowserServerState = { + server: null, + port: 18791, + resolved, + profiles: new Map([ + [ + "openclaw", + { + profile: openclawProfile!, + running: { pid: 123 } as never, + lastTargetId: "tab-1", + reconcile: null, + }, + ], + ]), + }; + + cfgProfiles.openclaw = { cdpPort: 19999, color: "#FF4500" }; + cachedConfig = null; + + refreshResolvedBrowserConfigFromDisk({ + current: state, + refreshConfigFromDisk: true, + mode: "cached", + }); + + const runtime = state.profiles.get("openclaw"); + expect(runtime).toBeTruthy(); + expect(runtime?.profile.cdpPort).toBe(19999); + expect(runtime?.lastTargetId).toBeNull(); + expect(runtime?.reconcile?.reason).toContain("cdpPort"); + }); }); diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index 7f890a2184cb5..09bc31cbf38e1 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserResetUnsupportedError } from "./errors.js"; import { stopChromeExtensionRelayServer } from "./extension-relay.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import type { ProfileRuntimeState } from "./server-context.types.js"; import { movePathToTrash } from "./trash.js"; @@ -32,13 +34,14 @@ export function createProfileResetOps({ isHttpReachable, resolveOpenClawUserDataDir, }: ResetDeps): ResetOps { + const capabilities = getBrowserProfileCapabilities(profile); const resetProfile = async () => { - if (profile.driver === "extension") { + if (capabilities.requiresRelay) { await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {}); return { moved: false, from: profile.cdpUrl }; } - if (!profile.cdpIsLoopback) { - throw new Error( + if (!capabilities.supportsReset) { + throw new BrowserResetUnsupportedError( `reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, ); } diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 7afeca36c5c44..8a9cfa19c4226 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -1,6 +1,8 @@ import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath } from "./cdp.js"; import type { ResolvedBrowserProfile } from "./config.js"; +import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import type { PwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js"; import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js"; @@ -28,13 +30,14 @@ export function createProfileSelectionOps({ openTab, }: SelectionDeps): SelectionOps { const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const capabilities = getBrowserProfileCapabilities(profile); const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); let tabs1 = await listTabs(); if (tabs1.length === 0) { - if (profile.driver === "extension") { + if (capabilities.requiresAttachedTab) { // Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker // lifecycle, relay restart). If we previously had a target selected, wait briefly for // the extension to reconnect and re-announce its attached tabs before failing. @@ -46,7 +49,7 @@ export function createProfileSelectionOps({ } } if (tabs1.length === 0) { - throw new Error( + throw new BrowserTabNotFoundError( `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", ); @@ -57,12 +60,7 @@ export function createProfileSelectionOps({ } const tabs = await listTabs(); - // For remote profiles using Playwright's persistent connection, we don't need wsUrl - // because we access pages directly through Playwright, not via individual WebSocket URLs. - const candidates = - profile.driver === "extension" || !profile.cdpIsLoopback - ? tabs - : tabs.filter((t) => Boolean(t.wsUrl)); + const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs; const resolveById = (raw: string) => { const resolved = resolveTargetIdFromTabs(raw, candidates); @@ -89,10 +87,10 @@ export function createProfileSelectionOps({ const chosen = targetId ? resolveById(targetId) : pickDefault(); if (chosen === "AMBIGUOUS") { - throw new Error("ambiguous target id prefix"); + throw new BrowserTargetAmbiguousError(); } if (!chosen) { - throw new Error("tab not found"); + throw new BrowserTabNotFoundError(); } profileState.lastTargetId = chosen.targetId; return chosen; @@ -103,9 +101,9 @@ export function createProfileSelectionOps({ const resolved = resolveTargetIdFromTabs(targetId, tabs); if (!resolved.ok) { if (resolved.reason === "ambiguous") { - throw new Error("ambiguous target id prefix"); + throw new BrowserTargetAmbiguousError(); } - throw new Error("tab not found"); + throw new BrowserTabNotFoundError(); } return resolved.targetId; }; @@ -113,7 +111,7 @@ export function createProfileSelectionOps({ const focusTab = async (targetId: string): Promise => { const resolvedTargetId = await resolveTargetIdOrThrow(targetId); - if (!profile.cdpIsLoopback) { + if (capabilities.usesPersistentPlaywright) { const mod = await getPwAiModule({ mode: "strict" }); const focusPageByTargetIdViaPlaywright = (mod as Partial | null) ?.focusPageByTargetIdViaPlaywright; @@ -137,7 +135,7 @@ export function createProfileSelectionOps({ const resolvedTargetId = await resolveTargetIdOrThrow(targetId); // For remote profiles, use Playwright's persistent connection to close tabs - if (!profile.cdpIsLoopback) { + if (capabilities.usesPersistentPlaywright) { const mod = await getPwAiModule({ mode: "strict" }); const closePageByTargetIdViaPlaywright = (mod as Partial | null) ?.closePageByTargetIdViaPlaywright; diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index fcf0d66ebceeb..5adbd45923e88 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -7,6 +7,7 @@ import { assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; +import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import type { PwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js"; import { @@ -59,10 +60,10 @@ export function createProfileTabOps({ getProfileState, }: TabOpsDeps): ProfileTabOps { const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); + const capabilities = getBrowserProfileCapabilities(profile); const listTabs = async (): Promise => { - // For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions - if (!profile.cdpIsLoopback) { + if (capabilities.usesPersistentPlaywright) { const mod = await getPwAiModule({ mode: "strict" }); const listPagesViaPlaywright = (mod as Partial | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { @@ -99,8 +100,7 @@ export function createProfileTabOps({ const enforceManagedTabLimit = async (keepTargetId: string): Promise => { const profileState = getProfileState(); if ( - profile.driver !== "openclaw" || - !profile.cdpIsLoopback || + !capabilities.supportsManagedTabLimit || state().resolved.attachOnly || !profileState.running ) { @@ -132,9 +132,7 @@ export function createProfileTabOps({ const openTab = async (url: string): Promise => { const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); - // For remote profiles, use Playwright's persistent connection to create tabs - // This ensures the tab persists beyond a single request. - if (!profile.cdpIsLoopback) { + if (capabilities.usesPersistentPlaywright) { const mod = await getPwAiModule({ mode: "strict" }); const createPageViaPlaywright = (mod as Partial | null)?.createPageViaPlaywright; if (typeof createPageViaPlaywright === "function") { diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 29632c7b8a456..d75b14c2471e6 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; +import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { refreshResolvedBrowserConfigFromDisk, @@ -57,7 +58,7 @@ function createProfileContext( const current = state(); let profileState = current.profiles.get(profile.name); if (!profileState) { - profileState = { profile, running: null, lastTargetId: null }; + profileState = { profile, running: null, lastTargetId: null, reconcile: null }; current.profiles.set(profile.name, profileState); } return profileState; @@ -136,7 +137,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon if (!profile) { const available = Object.keys(current.resolved.profiles).join(", "); - throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`); + throw new BrowserProfileNotFoundError( + `Profile "${name}" not found. Available profiles: ${available || "(none)"}`, + ); } return createProfileContext(opts, profile); }; @@ -150,9 +153,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon }); const result: ProfileStatus[] = []; - for (const name of Object.keys(current.resolved.profiles)) { + for (const name of listKnownProfileNames(current)) { const profileState = current.profiles.get(name); - const profile = resolveProfile(current.resolved, name); + const profile = resolveProfile(current.resolved, name) ?? profileState?.profile; if (!profile) { continue; } @@ -193,6 +196,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon tabCount, isDefault: name === current.resolved.defaultProfile, isRemote: !profile.cdpIsLoopback, + missingFromConfig: !(name in current.resolved.profiles) || undefined, + reconcileReason: profileState?.reconcile?.reason ?? null, }); } @@ -203,22 +208,16 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon const getDefaultContext = () => forProfile(); const mapTabError = (err: unknown) => { + const browserMapped = toBrowserErrorResponse(err); + if (browserMapped) { + return browserMapped; + } if (err instanceof SsrFBlockedError) { return { status: 400, message: err.message }; } if (err instanceof InvalidBrowserNavigationUrlError) { return { status: 400, message: err.message }; } - const msg = String(err); - if (msg.includes("ambiguous target id prefix")) { - return { status: 409, message: "ambiguous target id prefix" }; - } - if (msg.includes("tab not found")) { - return { status: 404, message: msg }; - } - if (msg.includes("not found")) { - return { status: 404, message: msg }; - } return null; }; diff --git a/src/browser/server-context.types.ts b/src/browser/server-context.types.ts index b9dc634fe93a9..f05e90e9e77e2 100644 --- a/src/browser/server-context.types.ts +++ b/src/browser/server-context.types.ts @@ -13,6 +13,10 @@ export type ProfileRuntimeState = { running: RunningChrome | null; /** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */ lastTargetId?: string | null; + reconcile?: { + previousProfile: ResolvedBrowserProfile; + reason: string; + } | null; }; export type BrowserServerState = { @@ -56,6 +60,8 @@ export type ProfileStatus = { tabCount: number; isDefault: boolean; isRemote: boolean; + missingFromConfig?: boolean; + reconcileReason?: string | null; }; export type ContextOptions = { diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index b65c74319a380..8d84ef3c7a8f8 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -116,6 +116,19 @@ describe("profile CRUD endpoints", () => { const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; expect(createBadRemoteBody.error).toContain("cdpUrl"); + const createBadExtension = await realFetch(`${base}/profiles/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "badextension", + driver: "extension", + cdpUrl: "http://10.0.0.42:9222", + }), + }); + expect(createBadExtension.status).toBe(400); + const createBadExtensionBody = (await createBadExtension.json()) as { error: string }; + expect(createBadExtensionBody.error).toContain("loopback cdpUrl host"); + const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, { method: "DELETE", }); diff --git a/src/browser/server.ts b/src/browser/server.ts index f6a269aee1eec..ce4a59419a44d 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -4,11 +4,10 @@ import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveBrowserConfig } from "./config.js"; import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js"; -import { isPwAiLoaded } from "./pw-ai-state.js"; import { registerBrowserRoutes } from "./routes/index.js"; import type { BrowserRouteRegistrar } from "./routes/types.js"; +import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; -import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; import { installBrowserAuthMiddleware, installBrowserCommonMiddleware, @@ -74,14 +73,9 @@ export async function startBrowserControlServerFromConfig(): Promise logServer.warn(message), }); @@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise { const current = state; - if (!current) { - return; - } - - await stopKnownBrowserProfiles({ + await stopBrowserRuntime({ + current, getState: () => state, + clearState: () => { + state = null; + }, + closeServer: true, onWarn: (message) => logServer.warn(message), }); - - if (current.server) { - await new Promise((resolve) => { - current.server?.close(() => resolve()); - }); - } - state = null; - - // Optional: avoid importing heavy Playwright bridge when this process never used it. - if (isPwAiLoaded()) { - try { - const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(); - } catch { - // ignore - } - } } From dfc18b7a2bdd217faf87e0188041d835c50c0dc6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:19:23 +0000 Subject: [PATCH 192/311] refactor(models): extract list row builders --- docs/refactor/cleanup.md | 7 + .../list.list-command.forward-compat.test.ts | 464 +++++++++--------- src/commands/models/list.list-command.ts | 144 ++---- src/commands/models/list.rows.ts | 178 +++++++ 4 files changed, 445 insertions(+), 348 deletions(-) create mode 100644 docs/refactor/cleanup.md create mode 100644 src/commands/models/list.rows.ts diff --git a/docs/refactor/cleanup.md b/docs/refactor/cleanup.md new file mode 100644 index 0000000000000..04fc1f143fb0b --- /dev/null +++ b/docs/refactor/cleanup.md @@ -0,0 +1,7 @@ +# Cleanup tracker + +- [x] Extract `models list` row/supplement helpers. +- [x] Split `models list` forward-compat tests by concern. +- [ ] Extract provider transport normalization from `pi-embedded-runner/model.ts`. +- [ ] Split `ensureOpenClawModelsJson()` into planning + IO layers. +- [ ] Split provider discovery helpers out of `models-config.providers.ts`. diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index d33ceb2aab18d..9dd7f55f7976c 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -1,7 +1,24 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const OPENAI_CODEX_MODEL = { + provider: "openai-codex", + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + input: ["text"], + contextWindow: 272000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +}; + +const OPENAI_CODEX_53_MODEL = { + ...OPENAI_CODEX_MODEL, + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", +}; const mocks = vi.hoisted(() => { - const printModelTable = vi.fn(); const sourceConfig = { agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, models: { @@ -23,48 +40,62 @@ const mocks = vi.hoisted(() => { }, }; return { - loadConfig: vi.fn().mockReturnValue({ - agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, - models: { providers: {} }, - }), sourceConfig, resolvedConfig, - loadModelsConfigWithSource: vi.fn().mockResolvedValue({ - sourceConfig, - resolvedConfig, - diagnostics: [], - }), - ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), - loadModelRegistry: vi - .fn() - .mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }), - loadModelCatalog: vi.fn().mockResolvedValue([]), - resolveConfiguredEntries: vi.fn().mockReturnValue({ - entries: [ - { - key: "openai-codex/gpt-5.4", - ref: { provider: "openai-codex", model: "gpt-5.4" }, - tags: new Set(["configured"]), - aliases: [], - }, - ], - }), - printModelTable, - listProfilesForProvider: vi.fn().mockReturnValue([]), - resolveModelWithRegistry: vi.fn().mockReturnValue({ - provider: "openai-codex", - id: "gpt-5.4", - name: "GPT-5.4", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - contextWindow: 272000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }), + loadConfig: vi.fn(), + loadModelsConfigWithSource: vi.fn(), + ensureAuthProfileStore: vi.fn(), + loadModelRegistry: vi.fn(), + loadModelCatalog: vi.fn(), + resolveConfiguredEntries: vi.fn(), + printModelTable: vi.fn(), + listProfilesForProvider: vi.fn(), + resolveModelWithRegistry: vi.fn(), }; }); +function resetMocks() { + mocks.loadConfig.mockReturnValue({ + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { providers: {} }, + }); + mocks.loadModelsConfigWithSource.mockResolvedValue({ + sourceConfig: mocks.sourceConfig, + resolvedConfig: mocks.resolvedConfig, + diagnostics: [], + }); + mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {}, order: {} }); + mocks.loadModelRegistry.mockResolvedValue({ + models: [], + availableKeys: new Set(), + registry: { + getAll: () => [], + }, + }); + mocks.loadModelCatalog.mockResolvedValue([]); + mocks.resolveConfiguredEntries.mockReturnValue({ + entries: [ + { + key: "openai-codex/gpt-5.4", + ref: { provider: "openai-codex", model: "gpt-5.4" }, + tags: new Set(["configured"]), + aliases: [], + }, + ], + }); + mocks.printModelTable.mockReset(); + mocks.listProfilesForProvider.mockReturnValue([]); + mocks.resolveModelWithRegistry.mockReturnValue({ ...OPENAI_CODEX_MODEL }); +} + +function createRuntime() { + return { log: vi.fn(), error: vi.fn() }; +} + +function lastPrintedRows() { + return (mocks.printModelTable.mock.calls.at(-1)?.[0] ?? []) as T[]; +} + vi.mock("../../config/config.js", () => ({ loadConfig: mocks.loadConfig, getRuntimeConfigSnapshot: vi.fn().mockReturnValue(null), @@ -114,188 +145,174 @@ vi.mock("../../agents/pi-embedded-runner/model.js", async (importOriginal) => { import { modelsListCommand } from "./list.list-command.js"; -describe("modelsListCommand forward-compat", () => { - it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => { - const runtime = { log: vi.fn(), error: vi.fn() }; - - await modelsListCommand({ json: true }, runtime as never); - - expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls[0]?.[0] as Array<{ - key: string; - tags: string[]; - missing: boolean; - }>; - - const codex = rows.find((r) => r.key === "openai-codex/gpt-5.4"); - expect(codex).toBeTruthy(); - expect(codex?.missing).toBe(false); - expect(codex?.tags).not.toContain("missing"); - }); +beforeEach(() => { + vi.clearAllMocks(); + resetMocks(); +}); - it("passes source config to model registry loading for persistence safety", async () => { - const runtime = { log: vi.fn(), error: vi.fn() }; +describe("modelsListCommand forward-compat", () => { + describe("configured rows", () => { + it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => { + const runtime = createRuntime(); - await modelsListCommand({ json: true }, runtime as never); + await modelsListCommand({ json: true }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, { - sourceConfig: mocks.sourceConfig, + expect(mocks.printModelTable).toHaveBeenCalled(); + const rows = lastPrintedRows<{ + key: string; + tags: string[]; + missing: boolean; + }>(); + + const codex = rows.find((row) => row.key === "openai-codex/gpt-5.4"); + expect(codex).toBeTruthy(); + expect(codex?.missing).toBe(false); + expect(codex?.tags).not.toContain("missing"); }); - }); - it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { - mocks.resolveConfiguredEntries.mockReturnValueOnce({ - entries: [ - { - key: "openai/gpt-5.4", - ref: { provider: "openai", model: "gpt-5.4" }, - tags: new Set(["configured"]), - aliases: [], - }, - ], - }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ - provider: "openai", - id: "gpt-5.4", - name: "GPT-5.4", - api: "openai-responses", - baseUrl: "http://localhost:4000/v1", - input: ["text", "image"], - contextWindow: 1_050_000, - maxTokens: 128_000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + it("passes source config to model registry loading for persistence safety", async () => { + const runtime = createRuntime(); + + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, { + sourceConfig: mocks.sourceConfig, + }); }); - const runtime = { log: vi.fn(), error: vi.fn() }; - await modelsListCommand({ json: true, local: true }, runtime as never); + it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ + entries: [ + { + key: "openai/gpt-5.4", + ref: { provider: "openai", model: "gpt-5.4" }, + tags: new Set(["configured"]), + aliases: [], + }, + ], + }); + mocks.resolveModelWithRegistry.mockReturnValueOnce({ + provider: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + baseUrl: "http://localhost:4000/v1", + input: ["text", "image"], + contextWindow: 1_050_000, + maxTokens: 128_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + const runtime = createRuntime(); - expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>; - expect(rows).toEqual([ - expect.objectContaining({ - key: "openai/gpt-5.4", - }), - ]); - }); + await modelsListCommand({ json: true, local: true }, runtime as never); - it("marks synthetic codex gpt-5.4 rows as available when provider auth exists", async () => { - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [], - availableKeys: new Set(), - registry: {}, + expect(mocks.printModelTable).toHaveBeenCalled(); + expect(lastPrintedRows<{ key: string }>()).toEqual([ + expect.objectContaining({ + key: "openai/gpt-5.4", + }), + ]); }); - mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], - ); - const runtime = { log: vi.fn(), error: vi.fn() }; + }); + + describe("availability fallback", () => { + it("marks synthetic codex gpt-5.4 rows as available when provider auth exists", async () => { + mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "openai-codex" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + const runtime = createRuntime(); - try { await modelsListCommand({ json: true }, runtime as never); expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ - key: string; - available: boolean; - }>; - - expect(rows).toContainEqual( + expect(lastPrintedRows<{ key: string; available: boolean }>()).toContainEqual( expect.objectContaining({ key: "openai-codex/gpt-5.4", available: true, }), ); - } finally { - mocks.listProfilesForProvider.mockReturnValue([]); - } + }); + + it("exits with an error when configured-mode listing has no model registry", async () => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [], + availableKeys: new Set(), + registry: undefined, + }); + const runtime = createRuntime(); + let observedExitCode: number | undefined; + + try { + await modelsListCommand({ json: true }, runtime as never); + observedExitCode = process.exitCode; + } finally { + process.exitCode = previousExitCode; + } + + expect(runtime.error).toHaveBeenCalledWith("Model registry unavailable."); + expect(observedExitCode).toBe(1); + expect(mocks.printModelTable).not.toHaveBeenCalled(); + }); }); - it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { - mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [ + describe("--all catalog supplementation", () => { + it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [{ ...OPENAI_CODEX_53_MODEL }], + availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), + registry: { + getAll: () => [{ ...OPENAI_CODEX_53_MODEL }], + }, + }); + mocks.loadModelCatalog.mockResolvedValueOnce([ { provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", input: ["text"], contextWindow: 272000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }, - ], - availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), - registry: {}, - }); - mocks.loadModelCatalog.mockResolvedValueOnce([ - { - provider: "openai-codex", - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - input: ["text"], - contextWindow: 272000, - }, - { - provider: "openai-codex", - id: "gpt-5.4", - name: "GPT-5.4", - input: ["text"], - contextWindow: 272000, - }, - ]); - mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => - provider === "openai-codex" ? ([{ id: "profile-1" }] as Array>) : [], - ); - mocks.resolveModelWithRegistry.mockImplementation( - ({ provider, modelId }: { provider: string; modelId: string }) => { - if (provider !== "openai-codex") { + { + provider: "openai-codex", + id: "gpt-5.4", + name: "GPT-5.4", + input: ["text"], + contextWindow: 272000, + }, + ]); + mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => + provider === "openai-codex" + ? ([{ id: "profile-1" }] as Array>) + : [], + ); + mocks.resolveModelWithRegistry.mockImplementation( + ({ provider, modelId }: { provider: string; modelId: string }) => { + if (provider !== "openai-codex") { + return undefined; + } + if (modelId === "gpt-5.3-codex") { + return { ...OPENAI_CODEX_53_MODEL }; + } + if (modelId === "gpt-5.4") { + return { ...OPENAI_CODEX_MODEL }; + } return undefined; - } - if (modelId === "gpt-5.3-codex") { - return { - provider: "openai-codex", - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - contextWindow: 272000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }; - } - if (modelId === "gpt-5.4") { - return { - provider: "openai-codex", - id: "gpt-5.4", - name: "GPT-5.4", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - contextWindow: 272000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }; - } - return undefined; - }, - ); - const runtime = { log: vi.fn(), error: vi.fn() }; + }, + ); + const runtime = createRuntime(); - try { await modelsListCommand( { all: true, provider: "openai-codex", json: true }, runtime as never, ); expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ - key: string; - available: boolean; - }>; - - expect(rows).toEqual([ + expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ expect.objectContaining({ key: "openai-codex/gpt-5.3-codex", }), @@ -304,66 +321,31 @@ describe("modelsListCommand forward-compat", () => { available: true, }), ]); - } finally { - mocks.listProfilesForProvider.mockReturnValue([]); - } - }); - - it("keeps discovered rows in --all output when catalog lookup is empty", async () => { - mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [ - { - provider: "openai-codex", - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - contextWindow: 272000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - ], - availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), - registry: {}, }); - mocks.loadModelCatalog.mockResolvedValueOnce([]); - const runtime = { log: vi.fn(), error: vi.fn() }; - - await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never); - expect(mocks.printModelTable).toHaveBeenCalled(); - const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>; + it("keeps discovered rows in --all output when catalog lookup is empty", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [{ ...OPENAI_CODEX_53_MODEL }], + availableKeys: new Set(["openai-codex/gpt-5.3-codex"]), + registry: { + getAll: () => [{ ...OPENAI_CODEX_53_MODEL }], + }, + }); + mocks.loadModelCatalog.mockResolvedValueOnce([]); + const runtime = createRuntime(); - expect(rows).toEqual([ - expect.objectContaining({ - key: "openai-codex/gpt-5.3-codex", - }), - ]); - }); + await modelsListCommand( + { all: true, provider: "openai-codex", json: true }, + runtime as never, + ); - it("exits with an error when configured-mode listing has no model registry", async () => { - vi.clearAllMocks(); - const previousExitCode = process.exitCode; - process.exitCode = undefined; - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [], - availableKeys: new Set(), - registry: undefined, + expect(mocks.printModelTable).toHaveBeenCalled(); + expect(lastPrintedRows<{ key: string }>()).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.3-codex", + }), + ]); }); - const runtime = { log: vi.fn(), error: vi.fn() }; - let observedExitCode: number | undefined; - - try { - await modelsListCommand({ json: true }, runtime as never); - observedExitCode = process.exitCode; - } finally { - process.exitCode = previousExitCode; - } - - expect(runtime.error).toHaveBeenCalledWith("Model registry unavailable."); - expect(observedExitCode).toBe(1); - expect(mocks.printModelTable).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index c19d18d9d11fd..d99a84199aa6b 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,16 +1,18 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; -import { loadModelCatalog } from "../../agents/model-catalog.js"; import { parseModelRef } from "../../agents/model-selection.js"; -import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js"; import type { RuntimeEnv } from "../../runtime.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; -import { loadModelRegistry, toModelRow } from "./list.registry.js"; +import { + appendCatalogSupplementRows, + appendConfiguredRows, + appendDiscoveredRows, + loadListModelRegistry, +} from "./list.rows.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; -import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js"; +import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; export async function modelsListCommand( opts: { @@ -39,17 +41,17 @@ export async function modelsListCommand( return parsed?.provider ?? raw.toLowerCase(); })(); - let models: Model[] = []; let modelRegistry: ModelRegistry | undefined; + let discoveredKeys = new Set(); let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { // Keep command behavior explicit: sync models.json from the source config // before building the read-only model registry view. await ensureOpenClawModelsJson(sourceConfig ?? cfg); - const loaded = await loadModelRegistry(cfg, { sourceConfig }); + const loaded = await loadListModelRegistry(cfg, { sourceConfig }); modelRegistry = loaded.registry; - models = loaded.models; + discoveredKeys = loaded.discoveredKeys; availableKeys = loaded.availableKeys; availabilityErrorMessage = loaded.availabilityErrorMessage; } catch (err) { @@ -62,83 +64,36 @@ export async function modelsListCommand( `Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`, ); } - const discoveredKeys = new Set(models.map((model) => modelKey(model.provider, model.id))); - const { entries } = resolveConfiguredEntries(cfg); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); const rows: ModelRow[] = []; + const rowContext = { + cfg, + authStore, + availableKeys, + configuredByKey, + discoveredKeys, + filter: { + provider: providerFilter, + local: opts.local, + }, + }; if (opts.all) { - const seenKeys = new Set(); - const sorted = [...models].toSorted((a, b) => { - const p = a.provider.localeCompare(b.provider); - if (p !== 0) { - return p; - } - return a.id.localeCompare(b.id); + const seenKeys = appendDiscoveredRows({ + rows, + models: modelRegistry?.getAll() ?? [], + context: rowContext, }); - for (const model of sorted) { - if (providerFilter && model.provider.toLowerCase() !== providerFilter) { - continue; - } - if (opts.local && !isLocalBaseUrl(model.baseUrl)) { - continue; - } - const key = modelKey(model.provider, model.id); - const configured = configuredByKey.get(key); - rows.push( - toModelRow({ - model, - key, - tags: configured ? Array.from(configured.tags) : [], - aliases: configured?.aliases ?? [], - availableKeys, - cfg, - authStore, - }), - ); - seenKeys.add(key); - } - if (modelRegistry) { - const catalog = await loadModelCatalog({ config: cfg }); - for (const entry of catalog) { - if (providerFilter && entry.provider.toLowerCase() !== providerFilter) { - continue; - } - const key = modelKey(entry.provider, entry.id); - if (seenKeys.has(key)) { - continue; - } - const model = resolveModelWithRegistry({ - provider: entry.provider, - modelId: entry.id, - modelRegistry, - cfg, - }); - if (!model) { - continue; - } - if (opts.local && !isLocalBaseUrl(model.baseUrl)) { - continue; - } - const configured = configuredByKey.get(key); - rows.push( - toModelRow({ - model, - key, - tags: configured ? Array.from(configured.tags) : [], - aliases: configured?.aliases ?? [], - availableKeys, - cfg, - authStore, - allowProviderAvailabilityFallback: !discoveredKeys.has(key), - }), - ); - seenKeys.add(key); - } + await appendCatalogSupplementRows({ + rows, + modelRegistry, + context: rowContext, + seenKeys, + }); } } else { const registry = modelRegistry; @@ -147,37 +102,12 @@ export async function modelsListCommand( process.exitCode = 1; return; } - for (const entry of entries) { - if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) { - continue; - } - const model = resolveModelWithRegistry({ - provider: entry.ref.provider, - modelId: entry.ref.model, - modelRegistry: registry, - cfg, - }); - if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) { - continue; - } - if (opts.local && !model) { - continue; - } - rows.push( - toModelRow({ - model, - key: entry.key, - tags: Array.from(entry.tags), - aliases: entry.aliases, - availableKeys, - cfg, - authStore, - allowProviderAvailabilityFallback: model - ? !discoveredKeys.has(modelKey(model.provider, model.id)) - : false, - }), - ); - } + appendConfiguredRows({ + rows, + entries, + modelRegistry: registry, + context: rowContext, + }); } if (rows.length === 0) { diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts new file mode 100644 index 0000000000000..c00d21fd6dff9 --- /dev/null +++ b/src/commands/models/list.rows.ts @@ -0,0 +1,178 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadModelRegistry, toModelRow } from "./list.registry.js"; +import type { ConfiguredEntry, ModelRow } from "./list.types.js"; +import { isLocalBaseUrl, modelKey } from "./shared.js"; + +type ConfiguredByKey = Map; + +type RowFilter = { + provider?: string; + local?: boolean; +}; + +type RowBuilderContext = { + cfg: OpenClawConfig; + authStore: AuthProfileStore; + availableKeys?: Set; + configuredByKey: ConfiguredByKey; + discoveredKeys: Set; + filter: RowFilter; +}; + +function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { + if (filter.provider && model.provider.toLowerCase() !== filter.provider) { + return false; + } + if (filter.local && !isLocalBaseUrl(model.baseUrl ?? "")) { + return false; + } + return true; +} + +function buildRow(params: { + model: Model; + key: string; + context: RowBuilderContext; + allowProviderAvailabilityFallback?: boolean; +}): ModelRow { + const configured = params.context.configuredByKey.get(params.key); + return toModelRow({ + model: params.model, + key: params.key, + tags: configured ? Array.from(configured.tags) : [], + aliases: configured?.aliases ?? [], + availableKeys: params.context.availableKeys, + cfg: params.context.cfg, + authStore: params.context.authStore, + allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback ?? false, + }); +} + +export async function loadListModelRegistry( + cfg: OpenClawConfig, + opts?: { sourceConfig?: OpenClawConfig }, +) { + const loaded = await loadModelRegistry(cfg, opts); + return { + ...loaded, + discoveredKeys: new Set(loaded.models.map((model) => modelKey(model.provider, model.id))), + }; +} + +export function appendDiscoveredRows(params: { + rows: ModelRow[]; + models: Model[]; + context: RowBuilderContext; +}): Set { + const seenKeys = new Set(); + const sorted = [...params.models].toSorted((a, b) => { + const providerCompare = a.provider.localeCompare(b.provider); + if (providerCompare !== 0) { + return providerCompare; + } + return a.id.localeCompare(b.id); + }); + + for (const model of sorted) { + if (!matchesRowFilter(params.context.filter, model)) { + continue; + } + const key = modelKey(model.provider, model.id); + params.rows.push( + buildRow({ + model, + key, + context: params.context, + }), + ); + seenKeys.add(key); + } + + return seenKeys; +} + +export async function appendCatalogSupplementRows(params: { + rows: ModelRow[]; + modelRegistry: ModelRegistry; + context: RowBuilderContext; + seenKeys: Set; +}): Promise { + const catalog = await loadModelCatalog({ config: params.context.cfg }); + for (const entry of catalog) { + if ( + params.context.filter.provider && + entry.provider.toLowerCase() !== params.context.filter.provider + ) { + continue; + } + const key = modelKey(entry.provider, entry.id); + if (params.seenKeys.has(key)) { + continue; + } + const model = resolveModelWithRegistry({ + provider: entry.provider, + modelId: entry.id, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + }); + if (!model || !matchesRowFilter(params.context.filter, model)) { + continue; + } + params.rows.push( + buildRow({ + model, + key, + context: params.context, + allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + }), + ); + params.seenKeys.add(key); + } +} + +export function appendConfiguredRows(params: { + rows: ModelRow[]; + entries: ConfiguredEntry[]; + modelRegistry: ModelRegistry; + context: RowBuilderContext; +}) { + for (const entry of params.entries) { + if ( + params.context.filter.provider && + entry.ref.provider.toLowerCase() !== params.context.filter.provider + ) { + continue; + } + const model = resolveModelWithRegistry({ + provider: entry.ref.provider, + modelId: entry.ref.model, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + }); + if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) { + continue; + } + if (params.context.filter.local && !model) { + continue; + } + params.rows.push( + toModelRow({ + model, + key: entry.key, + tags: Array.from(entry.tags), + aliases: entry.aliases, + availableKeys: params.context.availableKeys, + cfg: params.context.cfg, + authStore: params.context.authStore, + allowProviderAvailabilityFallback: model + ? !params.context.discoveredKeys.has(modelKey(model.provider, model.id)) + : false, + }), + ); + } +} From 24b53fcf4790a6fb4faf1a423ca6d38044883f0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:20:18 +0000 Subject: [PATCH 193/311] refactor(agents): extract provider model normalization --- docs/refactor/cleanup.md | 2 +- .../model.provider-normalization.ts | 62 +++++++++++++++++++ src/agents/pi-embedded-runner/model.ts | 56 +---------------- 3 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 src/agents/pi-embedded-runner/model.provider-normalization.ts diff --git a/docs/refactor/cleanup.md b/docs/refactor/cleanup.md index 04fc1f143fb0b..83b1fb47c5bf4 100644 --- a/docs/refactor/cleanup.md +++ b/docs/refactor/cleanup.md @@ -2,6 +2,6 @@ - [x] Extract `models list` row/supplement helpers. - [x] Split `models list` forward-compat tests by concern. -- [ ] Extract provider transport normalization from `pi-embedded-runner/model.ts`. +- [x] Extract provider transport normalization from `pi-embedded-runner/model.ts`. - [ ] Split `ensureOpenClawModelsJson()` into planning + IO layers. - [ ] Split provider discovery helpers out of `models-config.providers.ts`. diff --git a/src/agents/pi-embedded-runner/model.provider-normalization.ts b/src/agents/pi-embedded-runner/model.provider-normalization.ts new file mode 100644 index 0000000000000..ecf1a25e7d382 --- /dev/null +++ b/src/agents/pi-embedded-runner/model.provider-normalization.ts @@ -0,0 +1,62 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import { normalizeModelCompat } from "../model-compat.js"; +import { normalizeProviderId } from "../model-selection.js"; + +const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + +function isOpenAIApiBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); +} + +function isOpenAICodexBaseUrl(baseUrl?: string): boolean { + const trimmed = baseUrl?.trim(); + if (!trimmed) { + return false; + } + return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed); +} + +function normalizeOpenAICodexTransport(params: { + provider: string; + model: Model; +}): Model { + if (normalizeProviderId(params.provider) !== "openai-codex") { + return params.model; + } + + const useCodexTransport = + !params.model.baseUrl || + isOpenAIApiBaseUrl(params.model.baseUrl) || + isOpenAICodexBaseUrl(params.model.baseUrl); + + const nextApi = + useCodexTransport && params.model.api === "openai-responses" + ? ("openai-codex-responses" as const) + : params.model.api; + const nextBaseUrl = + nextApi === "openai-codex-responses" && + (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)) + ? OPENAI_CODEX_BASE_URL + : params.model.baseUrl; + + if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) { + return params.model; + } + + return { + ...params.model, + api: nextApi, + baseUrl: nextBaseUrl, + } as Model; +} + +export function normalizeResolvedProviderModel(params: { + provider: string; + model: Model; +}): Model { + return normalizeModelCompat(normalizeOpenAICodexTransport(params)); +} diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 5995bb4009938..638d66f787f2e 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -6,10 +6,10 @@ import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; -import { normalizeModelCompat } from "../model-compat.js"; import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; +import { normalizeResolvedProviderModel } from "./model.provider-normalization.js"; type InlineModelEntry = ModelDefinitionConfig & { provider: string; @@ -23,8 +23,6 @@ type InlineProviderConfig = { headers?: unknown; }; -const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - function sanitizeModelHeaders( headers: unknown, opts?: { stripSecretRefMarkers?: boolean }, @@ -45,58 +43,8 @@ function sanitizeModelHeaders( return Object.keys(next).length > 0 ? next : undefined; } -function isOpenAIApiBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed); -} - -function isOpenAICodexBaseUrl(baseUrl?: string): boolean { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return false; - } - return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed); -} - -function normalizeOpenAICodexTransport(params: { - provider: string; - model: Model; -}): Model { - if (normalizeProviderId(params.provider) !== "openai-codex") { - return params.model; - } - - const useCodexTransport = - !params.model.baseUrl || - isOpenAIApiBaseUrl(params.model.baseUrl) || - isOpenAICodexBaseUrl(params.model.baseUrl); - - const nextApi = - useCodexTransport && params.model.api === "openai-responses" - ? ("openai-codex-responses" as const) - : params.model.api; - const nextBaseUrl = - nextApi === "openai-codex-responses" && - (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)) - ? OPENAI_CODEX_BASE_URL - : params.model.baseUrl; - - if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) { - return params.model; - } - - return { - ...params.model, - api: nextApi, - baseUrl: nextBaseUrl, - } as Model; -} - function normalizeResolvedModel(params: { provider: string; model: Model }): Model { - return normalizeModelCompat(normalizeOpenAICodexTransport(params)); + return normalizeResolvedProviderModel(params); } export { buildModelAliasLines }; From c29b09874416f311fdb75333cf136d472986769f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:21:37 +0000 Subject: [PATCH 194/311] refactor(models): split models.json planning from writes --- docs/refactor/cleanup.md | 2 +- src/agents/models-config.plan.ts | 128 +++++++++++++++++++++++++++++++ src/agents/models-config.ts | 107 +++----------------------- 3 files changed, 141 insertions(+), 96 deletions(-) create mode 100644 src/agents/models-config.plan.ts diff --git a/docs/refactor/cleanup.md b/docs/refactor/cleanup.md index 83b1fb47c5bf4..f825164c2784a 100644 --- a/docs/refactor/cleanup.md +++ b/docs/refactor/cleanup.md @@ -3,5 +3,5 @@ - [x] Extract `models list` row/supplement helpers. - [x] Split `models list` forward-compat tests by concern. - [x] Extract provider transport normalization from `pi-embedded-runner/model.ts`. -- [ ] Split `ensureOpenClawModelsJson()` into planning + IO layers. +- [x] Split `ensureOpenClawModelsJson()` into planning + IO layers. - [ ] Split provider discovery helpers out of `models-config.providers.ts`. diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts new file mode 100644 index 0000000000000..40777c2cd0dd0 --- /dev/null +++ b/src/agents/models-config.plan.ts @@ -0,0 +1,128 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { isRecord } from "../utils.js"; +import { + mergeProviders, + mergeWithExistingProviderSecrets, + type ExistingProviderConfig, +} from "./models-config.merge.js"; +import { + normalizeProviders, + resolveImplicitProviders, + type ProviderConfig, +} from "./models-config.providers.js"; + +type ModelsConfig = NonNullable; + +export type ModelsJsonPlan = + | { + action: "skip"; + } + | { + action: "noop"; + } + | { + action: "write"; + contents: string; + }; + +async function resolveProvidersForModelsJson(params: { + cfg: OpenClawConfig; + agentDir: string; + env: NodeJS.ProcessEnv; +}): Promise> { + const { cfg, agentDir, env } = params; + const explicitProviders = cfg.models?.providers ?? {}; + const implicitProviders = await resolveImplicitProviders({ + agentDir, + config: cfg, + env, + explicitProviders, + }); + return mergeProviders({ + implicit: implicitProviders, + explicit: explicitProviders, + }); +} + +function resolveExplicitBaseUrlProviders( + providers: OpenClawConfig["models"] | undefined, +): ReadonlySet { + return new Set( + Object.entries(providers?.providers ?? {}) + .map(([key, provider]) => [key.trim(), provider] as const) + .filter( + ([key, provider]) => + Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(), + ) + .map(([key]) => key), + ); +} + +async function resolveProvidersForMode(params: { + mode: NonNullable; + existingParsed: unknown; + providers: Record; + secretRefManagedProviders: ReadonlySet; + explicitBaseUrlProviders: ReadonlySet; +}): Promise> { + if (params.mode !== "merge") { + return params.providers; + } + const existing = params.existingParsed; + if (!isRecord(existing) || !isRecord(existing.providers)) { + return params.providers; + } + const existingProviders = existing.providers as Record< + string, + NonNullable[string] + >; + return mergeWithExistingProviderSecrets({ + nextProviders: params.providers, + existingProviders: existingProviders as Record, + secretRefManagedProviders: params.secretRefManagedProviders, + explicitBaseUrlProviders: params.explicitBaseUrlProviders, + }); +} + +export async function planOpenClawModelsJson(params: { + cfg: OpenClawConfig; + agentDir: string; + env: NodeJS.ProcessEnv; + existingRaw: string; + existingParsed: unknown; +}): Promise { + const { cfg, agentDir, env } = params; + const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env }); + + if (Object.keys(providers).length === 0) { + return { action: "skip" }; + } + + const mode = cfg.models?.mode ?? "merge"; + const secretRefManagedProviders = new Set(); + const normalizedProviders = + normalizeProviders({ + providers, + agentDir, + env, + secretDefaults: cfg.secrets?.defaults, + secretRefManagedProviders, + }) ?? providers; + const mergedProviders = await resolveProvidersForMode({ + mode, + existingParsed: params.existingParsed, + providers: normalizedProviders, + secretRefManagedProviders, + explicitBaseUrlProviders: resolveExplicitBaseUrlProviders(cfg.models), + }); + const nextContents = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; + + if (params.existingRaw === nextContents) { + return { action: "noop" }; + } + + return { + action: "write", + contents: nextContents, + }; +} diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 8fa237fcaf3e1..b9b8a7316d33a 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -7,22 +7,9 @@ import { loadConfig, } from "../config/config.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; -import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; -import { - mergeProviders, - mergeWithExistingProviderSecrets, - type ExistingProviderConfig, -} from "./models-config.merge.js"; -import { - normalizeProviders, - type ProviderConfig, - resolveImplicitProviders, -} from "./models-config.providers.js"; - -type ModelsConfig = NonNullable; +import { planOpenClawModelsJson } from "./models-config.plan.js"; -const DEFAULT_MODE: NonNullable = "merge"; const MODELS_JSON_WRITE_LOCKS = new Map>(); async function readExistingModelsFile(pathname: string): Promise<{ @@ -43,52 +30,6 @@ async function readExistingModelsFile(pathname: string): Promise<{ } } -async function resolveProvidersForModelsJson(params: { - cfg: OpenClawConfig; - agentDir: string; - env: NodeJS.ProcessEnv; -}): Promise> { - const { cfg, agentDir, env } = params; - const explicitProviders = cfg.models?.providers ?? {}; - const implicitProviders = await resolveImplicitProviders({ - agentDir, - config: cfg, - env, - explicitProviders, - }); - const providers: Record = mergeProviders({ - implicit: implicitProviders, - explicit: explicitProviders, - }); - return providers; -} - -async function resolveProvidersForMode(params: { - mode: NonNullable; - existingParsed: unknown; - providers: Record; - secretRefManagedProviders: ReadonlySet; - explicitBaseUrlProviders: ReadonlySet; -}): Promise> { - if (params.mode !== "merge") { - return params.providers; - } - const existing = params.existingParsed; - if (!isRecord(existing) || !isRecord(existing.providers)) { - return params.providers; - } - const existingProviders = existing.providers as Record< - string, - NonNullable[string] - >; - return mergeWithExistingProviderSecrets({ - nextProviders: params.providers, - existingProviders: existingProviders as Record, - secretRefManagedProviders: params.secretRefManagedProviders, - explicitBaseUrlProviders: params.explicitBaseUrlProviders, - }); -} - async function ensureModelsFileMode(pathname: string): Promise { await fs.chmod(pathname, 0o600).catch(() => { // best-effort @@ -147,50 +88,26 @@ export async function ensureOpenClawModelsJson( // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are // are available to provider discovery without mutating process.env. const env = createConfigRuntimeEnv(cfg); - - const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env }); - - if (Object.keys(providers).length === 0) { - return { agentDir, wrote: false }; - } - - const mode = cfg.models?.mode ?? DEFAULT_MODE; - const secretRefManagedProviders = new Set(); - const explicitBaseUrlProviders = new Set( - Object.entries(cfg.models?.providers ?? {}) - .map(([key, provider]) => [key.trim(), provider] as const) - .filter( - ([key, provider]) => - Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(), - ) - .map(([key]) => key), - ); - - const normalizedProviders = - normalizeProviders({ - providers, - agentDir, - env, - secretDefaults: cfg.secrets?.defaults, - secretRefManagedProviders, - }) ?? providers; const existingModelsFile = await readExistingModelsFile(targetPath); - const mergedProviders = await resolveProvidersForMode({ - mode, + const plan = await planOpenClawModelsJson({ + cfg, + agentDir, + env, + existingRaw: existingModelsFile.raw, existingParsed: existingModelsFile.parsed, - providers: normalizedProviders, - secretRefManagedProviders, - explicitBaseUrlProviders, }); - const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; - if (existingModelsFile.raw === next) { + if (plan.action === "skip") { + return { agentDir, wrote: false }; + } + + if (plan.action === "noop") { await ensureModelsFileMode(targetPath); return { agentDir, wrote: false }; } await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await writeModelsFileAtomic(targetPath, next); + await writeModelsFileAtomic(targetPath, plan.contents); await ensureModelsFileMode(targetPath); return { agentDir, wrote: true }; }); From ff4745fc3fb108e72e1d36922810695128e0bc1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:24:02 +0000 Subject: [PATCH 195/311] refactor(models): split provider discovery helpers --- docs/refactor/cleanup.md | 2 +- .../models-config.providers.discovery.ts | 292 +++++++++++++++++ src/agents/models-config.providers.ts | 302 +----------------- 3 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 src/agents/models-config.providers.discovery.ts diff --git a/docs/refactor/cleanup.md b/docs/refactor/cleanup.md index f825164c2784a..142bcda315654 100644 --- a/docs/refactor/cleanup.md +++ b/docs/refactor/cleanup.md @@ -4,4 +4,4 @@ - [x] Split `models list` forward-compat tests by concern. - [x] Extract provider transport normalization from `pi-embedded-runner/model.ts`. - [x] Split `ensureOpenClawModelsJson()` into planning + IO layers. -- [ ] Split provider discovery helpers out of `models-config.providers.ts`. +- [x] Split provider discovery helpers out of `models-config.providers.ts`. diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts new file mode 100644 index 0000000000000..caab5cafb4ecd --- /dev/null +++ b/src/agents/models-config.providers.discovery.ts @@ -0,0 +1,292 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; +import { + discoverHuggingfaceModels, + HUGGINGFACE_BASE_URL, + HUGGINGFACE_MODEL_CATALOG, + buildHuggingfaceModelDefinition, +} from "./huggingface-models.js"; +import { discoverKilocodeModels } from "./kilocode-models.js"; +import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; +import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; + +type ModelsConfig = NonNullable; +type ProviderConfig = NonNullable[string]; + +const log = createSubsystemLogger("agents/model-providers"); + +const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL; +const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL; +const OLLAMA_SHOW_CONCURRENCY = 8; +const OLLAMA_SHOW_MAX_MODELS = 200; +const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; +const OLLAMA_DEFAULT_MAX_TOKENS = 8192; +const OLLAMA_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const VLLM_BASE_URL = "http://127.0.0.1:8000/v1"; +const VLLM_DEFAULT_CONTEXT_WINDOW = 128000; +const VLLM_DEFAULT_MAX_TOKENS = 8192; +const VLLM_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +interface OllamaModel { + name: string; + modified_at: string; + size: number; + digest: string; + details?: { + family?: string; + parameter_size?: string; + }; +} + +interface OllamaTagsResponse { + models: OllamaModel[]; +} + +type VllmModelsResponse = { + data?: Array<{ + id?: string; + }>; +}; + +/** + * Derive the Ollama native API base URL from a configured base URL. + * + * Users typically configure `baseUrl` with a `/v1` suffix (e.g. + * `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint. + * The native Ollama API lives at the root (e.g. `/api/tags`), so we + * strip the `/v1` suffix when present. + */ +export function resolveOllamaApiBase(configuredBaseUrl?: string): string { + if (!configuredBaseUrl) { + return OLLAMA_API_BASE_URL; + } + // Strip trailing slash, then strip /v1 suffix if present + const trimmed = configuredBaseUrl.replace(/\/+$/, ""); + return trimmed.replace(/\/v1$/i, ""); +} + +async function queryOllamaContextWindow( + apiBase: string, + modelName: string, +): Promise { + try { + const response = await fetch(`${apiBase}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: modelName }), + signal: AbortSignal.timeout(3000), + }); + if (!response.ok) { + return undefined; + } + const data = (await response.json()) as { model_info?: Record }; + if (!data.model_info) { + return undefined; + } + for (const [key, value] of Object.entries(data.model_info)) { + if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) { + const contextWindow = Math.floor(value); + if (contextWindow > 0) { + return contextWindow; + } + } + } + return undefined; + } catch { + return undefined; + } +} + +async function discoverOllamaModels( + baseUrl?: string, + opts?: { quiet?: boolean }, +): Promise { + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return []; + } + try { + const apiBase = resolveOllamaApiBase(baseUrl); + const response = await fetch(`${apiBase}/api/tags`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + if (!opts?.quiet) { + log.warn(`Failed to discover Ollama models: ${response.status}`); + } + return []; + } + const data = (await response.json()) as OllamaTagsResponse; + if (!data.models || data.models.length === 0) { + log.debug("No Ollama models found on local instance"); + return []; + } + const modelsToInspect = data.models.slice(0, OLLAMA_SHOW_MAX_MODELS); + if (modelsToInspect.length < data.models.length && !opts?.quiet) { + log.warn( + `Capping Ollama /api/show inspection to ${OLLAMA_SHOW_MAX_MODELS} models (received ${data.models.length})`, + ); + } + const discovered: ModelDefinitionConfig[] = []; + for (let index = 0; index < modelsToInspect.length; index += OLLAMA_SHOW_CONCURRENCY) { + const batch = modelsToInspect.slice(index, index + OLLAMA_SHOW_CONCURRENCY); + const batchDiscovered = await Promise.all( + batch.map(async (model) => { + const modelId = model.name; + const contextWindow = await queryOllamaContextWindow(apiBase, modelId); + const isReasoning = + modelId.toLowerCase().includes("r1") || modelId.toLowerCase().includes("reasoning"); + return { + id: modelId, + name: modelId, + reasoning: isReasoning, + input: ["text"], + cost: OLLAMA_DEFAULT_COST, + contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW, + maxTokens: OLLAMA_DEFAULT_MAX_TOKENS, + } satisfies ModelDefinitionConfig; + }), + ); + discovered.push(...batchDiscovered); + } + return discovered; + } catch (error) { + if (!opts?.quiet) { + log.warn(`Failed to discover Ollama models: ${String(error)}`); + } + return []; + } +} + +async function discoverVllmModels( + baseUrl: string, + apiKey?: string, +): Promise { + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return []; + } + + const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); + const url = `${trimmedBaseUrl}/models`; + + try { + const trimmedApiKey = apiKey?.trim(); + const response = await fetch(url, { + headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + log.warn(`Failed to discover vLLM models: ${response.status}`); + return []; + } + const data = (await response.json()) as VllmModelsResponse; + const models = data.data ?? []; + if (models.length === 0) { + log.warn("No vLLM models found on local instance"); + return []; + } + + return models + .map((model) => ({ id: typeof model.id === "string" ? model.id.trim() : "" })) + .filter((model) => Boolean(model.id)) + .map((model) => { + const modelId = model.id; + const lower = modelId.toLowerCase(); + const isReasoning = + lower.includes("r1") || lower.includes("reasoning") || lower.includes("think"); + return { + id: modelId, + name: modelId, + reasoning: isReasoning, + input: ["text"], + cost: VLLM_DEFAULT_COST, + contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW, + maxTokens: VLLM_DEFAULT_MAX_TOKENS, + } satisfies ModelDefinitionConfig; + }); + } catch (error) { + log.warn(`Failed to discover vLLM models: ${String(error)}`); + return []; + } +} + +export async function buildVeniceProvider(): Promise { + const models = await discoverVeniceModels(); + return { + baseUrl: VENICE_BASE_URL, + api: "openai-completions", + models, + }; +} + +export async function buildOllamaProvider( + configuredBaseUrl?: string, + opts?: { quiet?: boolean }, +): Promise { + const models = await discoverOllamaModels(configuredBaseUrl, opts); + return { + baseUrl: resolveOllamaApiBase(configuredBaseUrl), + api: "ollama", + models, + }; +} + +export async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { + const resolvedSecret = discoveryApiKey?.trim() ?? ""; + const models = + resolvedSecret !== "" + ? await discoverHuggingfaceModels(resolvedSecret) + : HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); + return { + baseUrl: HUGGINGFACE_BASE_URL, + api: "openai-completions", + models, + }; +} + +export async function buildVercelAiGatewayProvider(): Promise { + return { + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + api: "anthropic-messages", + models: await discoverVercelAiGatewayModels(), + }; +} + +export async function buildVllmProvider(params?: { + baseUrl?: string; + apiKey?: string; +}): Promise { + const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, ""); + const models = await discoverVllmModels(baseUrl, params?.apiKey); + return { + baseUrl, + api: "openai-completions", + models, + }; +} + +/** + * Build the Kilocode provider with dynamic model discovery from the gateway + * API. Falls back to the static catalog on failure. + */ +export async function buildKilocodeProviderWithDiscovery(): Promise { + const models = await discoverKilocodeModels(); + return { + baseUrl: KILOCODE_BASE_URL, + api: "openai-completions", + models, + }; +} diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 48be848dcbe16..8f8ffb9201c52 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,12 +1,9 @@ import type { OpenClawConfig } from "../config/config.js"; -import type { ModelDefinitionConfig } from "../config/types.models.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken, } from "../providers/github-copilot-token.js"; -import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; @@ -15,12 +12,14 @@ import { resolveCloudflareAiGatewayBaseUrl, } from "./cloudflare-ai-gateway.js"; import { - discoverHuggingfaceModels, - HUGGINGFACE_BASE_URL, - HUGGINGFACE_MODEL_CATALOG, - buildHuggingfaceModelDefinition, -} from "./huggingface-models.js"; -import { discoverKilocodeModels } from "./kilocode-models.js"; + buildHuggingfaceProvider, + buildKilocodeProviderWithDiscovery, + buildOllamaProvider, + buildVeniceProvider, + buildVercelAiGatewayProvider, + buildVllmProvider, + resolveOllamaApiBase, +} from "./models-config.providers.discovery.js"; import { buildBytePlusCodingProvider, buildBytePlusProvider, @@ -63,222 +62,11 @@ import { resolveEnvSecretRefHeaderValueMarker, } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; -import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; -import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; -import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js"; +export { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; -const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL; -const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL; -const OLLAMA_SHOW_CONCURRENCY = 8; -const OLLAMA_SHOW_MAX_MODELS = 200; -const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000; -const OLLAMA_DEFAULT_MAX_TOKENS = 8192; -const OLLAMA_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const VLLM_BASE_URL = "http://127.0.0.1:8000/v1"; -const VLLM_DEFAULT_CONTEXT_WINDOW = 128000; -const VLLM_DEFAULT_MAX_TOKENS = 8192; -const VLLM_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const log = createSubsystemLogger("agents/model-providers"); - -interface OllamaModel { - name: string; - modified_at: string; - size: number; - digest: string; - details?: { - family?: string; - parameter_size?: string; - }; -} - -interface OllamaTagsResponse { - models: OllamaModel[]; -} - -type VllmModelsResponse = { - data?: Array<{ - id?: string; - }>; -}; - -/** - * Derive the Ollama native API base URL from a configured base URL. - * - * Users typically configure `baseUrl` with a `/v1` suffix (e.g. - * `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint. - * The native Ollama API lives at the root (e.g. `/api/tags`), so we - * strip the `/v1` suffix when present. - */ -export function resolveOllamaApiBase(configuredBaseUrl?: string): string { - if (!configuredBaseUrl) { - return OLLAMA_API_BASE_URL; - } - // Strip trailing slash, then strip /v1 suffix if present - const trimmed = configuredBaseUrl.replace(/\/+$/, ""); - return trimmed.replace(/\/v1$/i, ""); -} - -async function queryOllamaContextWindow( - apiBase: string, - modelName: string, -): Promise { - try { - const response = await fetch(`${apiBase}/api/show`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: modelName }), - signal: AbortSignal.timeout(3000), - }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { model_info?: Record }; - if (!data.model_info) { - return undefined; - } - for (const [key, value] of Object.entries(data.model_info)) { - if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) { - const contextWindow = Math.floor(value); - if (contextWindow > 0) { - return contextWindow; - } - } - } - return undefined; - } catch { - return undefined; - } -} - -async function discoverOllamaModels( - baseUrl?: string, - opts?: { quiet?: boolean }, -): Promise { - // Skip Ollama discovery in test environments - if (process.env.VITEST || process.env.NODE_ENV === "test") { - return []; - } - try { - const apiBase = resolveOllamaApiBase(baseUrl); - const response = await fetch(`${apiBase}/api/tags`, { - signal: AbortSignal.timeout(5000), - }); - if (!response.ok) { - if (!opts?.quiet) { - log.warn(`Failed to discover Ollama models: ${response.status}`); - } - return []; - } - const data = (await response.json()) as OllamaTagsResponse; - if (!data.models || data.models.length === 0) { - log.debug("No Ollama models found on local instance"); - return []; - } - const modelsToInspect = data.models.slice(0, OLLAMA_SHOW_MAX_MODELS); - if (modelsToInspect.length < data.models.length && !opts?.quiet) { - log.warn( - `Capping Ollama /api/show inspection to ${OLLAMA_SHOW_MAX_MODELS} models (received ${data.models.length})`, - ); - } - const discovered: ModelDefinitionConfig[] = []; - for (let index = 0; index < modelsToInspect.length; index += OLLAMA_SHOW_CONCURRENCY) { - const batch = modelsToInspect.slice(index, index + OLLAMA_SHOW_CONCURRENCY); - const batchDiscovered = await Promise.all( - batch.map(async (model) => { - const modelId = model.name; - const contextWindow = await queryOllamaContextWindow(apiBase, modelId); - const isReasoning = - modelId.toLowerCase().includes("r1") || modelId.toLowerCase().includes("reasoning"); - return { - id: modelId, - name: modelId, - reasoning: isReasoning, - input: ["text"], - cost: OLLAMA_DEFAULT_COST, - contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW, - maxTokens: OLLAMA_DEFAULT_MAX_TOKENS, - } satisfies ModelDefinitionConfig; - }), - ); - discovered.push(...batchDiscovered); - } - return discovered; - } catch (error) { - if (!opts?.quiet) { - log.warn(`Failed to discover Ollama models: ${String(error)}`); - } - return []; - } -} - -async function discoverVllmModels( - baseUrl: string, - apiKey?: string, -): Promise { - // Skip vLLM discovery in test environments - if (process.env.VITEST || process.env.NODE_ENV === "test") { - return []; - } - - const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); - const url = `${trimmedBaseUrl}/models`; - - try { - const trimmedApiKey = apiKey?.trim(); - const response = await fetch(url, { - headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined, - signal: AbortSignal.timeout(5000), - }); - if (!response.ok) { - log.warn(`Failed to discover vLLM models: ${response.status}`); - return []; - } - const data = (await response.json()) as VllmModelsResponse; - const models = data.data ?? []; - if (models.length === 0) { - log.warn("No vLLM models found on local instance"); - return []; - } - - return models - .map((m) => ({ id: typeof m.id === "string" ? m.id.trim() : "" })) - .filter((m) => Boolean(m.id)) - .map((m) => { - const modelId = m.id; - const lower = modelId.toLowerCase(); - const isReasoning = - lower.includes("r1") || lower.includes("reasoning") || lower.includes("think"); - return { - id: modelId, - name: modelId, - reasoning: isReasoning, - input: ["text"], - cost: VLLM_DEFAULT_COST, - contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW, - maxTokens: VLLM_DEFAULT_MAX_TOKENS, - } satisfies ModelDefinitionConfig; - }); - } catch (error) { - log.warn(`Failed to discover vLLM models: ${String(error)}`); - return []; - } -} - const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; function normalizeApiKeyConfig(value: string): string { @@ -641,78 +429,6 @@ export function normalizeProviders(params: { return mutated ? next : providers; } -async function buildVeniceProvider(): Promise { - const models = await discoverVeniceModels(); - return { - baseUrl: VENICE_BASE_URL, - api: "openai-completions", - models, - }; -} - -async function buildOllamaProvider( - configuredBaseUrl?: string, - opts?: { quiet?: boolean }, -): Promise { - const models = await discoverOllamaModels(configuredBaseUrl, opts); - return { - baseUrl: resolveOllamaApiBase(configuredBaseUrl), - api: "ollama", - models, - }; -} - -async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { - const resolvedSecret = toDiscoveryApiKey(discoveryApiKey) ?? ""; - const models = - resolvedSecret !== "" - ? await discoverHuggingfaceModels(resolvedSecret) - : HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - return { - baseUrl: HUGGINGFACE_BASE_URL, - api: "openai-completions", - models, - }; -} - -async function buildVercelAiGatewayProvider(): Promise { - return { - baseUrl: VERCEL_AI_GATEWAY_BASE_URL, - api: "anthropic-messages", - models: await discoverVercelAiGatewayModels(), - }; -} - -async function buildVllmProvider(params?: { - baseUrl?: string; - apiKey?: string; -}): Promise { - const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, ""); - const models = await discoverVllmModels(baseUrl, params?.apiKey); - return { - baseUrl, - api: "openai-completions", - models, - }; -} - -/** - * Build the Kilocode provider with dynamic model discovery from the gateway - * API. Falls back to the static catalog on failure. - * - * Used by {@link resolveImplicitProviders} (async context). The sync - * {@link buildKilocodeProvider} is kept for the onboarding config path - * which cannot await. - */ -async function buildKilocodeProviderWithDiscovery(): Promise { - const models = await discoverKilocodeModels(); - return { - baseUrl: KILOCODE_BASE_URL, - api: "openai-completions", - models, - }; -} - type ImplicitProviderParams = { agentDir: string; config?: OpenClawConfig; From 13bd3db307bc5a6194a0799893d0fab7030791dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:24:33 +0000 Subject: [PATCH 196/311] chore(docs): drop refactor cleanup tracker --- docs/refactor/cleanup.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 docs/refactor/cleanup.md diff --git a/docs/refactor/cleanup.md b/docs/refactor/cleanup.md deleted file mode 100644 index 142bcda315654..0000000000000 --- a/docs/refactor/cleanup.md +++ /dev/null @@ -1,7 +0,0 @@ -# Cleanup tracker - -- [x] Extract `models list` row/supplement helpers. -- [x] Split `models list` forward-compat tests by concern. -- [x] Extract provider transport normalization from `pi-embedded-runner/model.ts`. -- [x] Split `ensureOpenClawModelsJson()` into planning + IO layers. -- [x] Split provider discovery helpers out of `models-config.providers.ts`. From 4f42c03a49e18fdecc4a433a68dc95e12ff058c3 Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 9 Mar 2026 01:50:42 +0100 Subject: [PATCH 197/311] gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385) Merged via squash. Prepared head SHA: 567b3ed68434220bb319a940fa1b834a2f520ff5 Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + src/cli/daemon-cli/lifecycle.test.ts | 21 ++-- src/gateway/control-ui.auto-root.http.test.ts | 101 ++++++++++++++++ src/gateway/control-ui.http.test.ts | 69 ++++++++++- src/gateway/control-ui.ts | 23 +++- src/gateway/server.control-ui-root.test.ts | 46 +++++++ src/gateway/server.impl.ts | 12 +- src/infra/control-ui-assets.test.ts | 58 +++++++++ src/infra/control-ui-assets.ts | 114 ++++++++++++++---- 9 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 src/gateway/control-ui.auto-root.http.test.ts create mode 100644 src/gateway/server.control-ui-root.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c61e76faa148..7be20082a5fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. - Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232) - Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending. +- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @velvet-shark. ## 2026.3.7 diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 3f0ed6d531c30..f1e87fc4938c0 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = - vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> - >(); +const probeGateway = vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> +>(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts new file mode 100644 index 0000000000000..523700083d680 --- /dev/null +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs/promises"; +import type { IncomingMessage } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { resolveControlUiRootSyncMock, isPackageProvenControlUiRootSyncMock } = vi.hoisted(() => ({ + resolveControlUiRootSyncMock: vi.fn(), + isPackageProvenControlUiRootSyncMock: vi.fn().mockReturnValue(true), +})); + +vi.mock("../infra/control-ui-assets.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveControlUiRootSync: resolveControlUiRootSyncMock, + isPackageProvenControlUiRootSync: isPackageProvenControlUiRootSyncMock, + }; +}); + +const { handleControlUiHttpRequest } = await import("./control-ui.js"); +const { makeMockHttpResponse } = await import("./test-http-response.js"); + +async function withControlUiRoot(fn: (tmp: string) => Promise) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-auto-root-")); + try { + await fs.writeFile(path.join(tmp, "index.html"), "fallback\n"); + return await fn(tmp); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +} + +afterEach(() => { + resolveControlUiRootSyncMock.mockReset(); + isPackageProvenControlUiRootSyncMock.mockReset(); + isPackageProvenControlUiRootSyncMock.mockReturnValue(true); +}); + +describe("handleControlUiHttpRequest auto-detected root", () => { + it("serves hardlinked asset files for bundled auto-detected roots", async () => { + await withControlUiRoot(async (tmp) => { + const assetsDir = path.join(tmp, "assets"); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');"); + await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js")); + resolveControlUiRootSyncMock.mockReturnValue(tmp); + + const { res, end } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: "/assets/app.hl.js", method: "GET" } as IncomingMessage, + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');"); + }); + }); + + it("serves hardlinked SPA fallback index.html for bundled auto-detected roots", async () => { + await withControlUiRoot(async (tmp) => { + const sourceIndex = path.join(tmp, "index.source.html"); + const indexPath = path.join(tmp, "index.html"); + await fs.writeFile(sourceIndex, "fallback-hardlink\n"); + await fs.rm(indexPath); + await fs.link(sourceIndex, indexPath); + resolveControlUiRootSyncMock.mockReturnValue(tmp); + + const { res, end } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: "/dashboard", method: "GET" } as IncomingMessage, + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("fallback-hardlink\n"); + }); + }); + + it("rejects hardlinked assets for non-package-proven auto-detected roots", async () => { + isPackageProvenControlUiRootSyncMock.mockReturnValue(false); + await withControlUiRoot(async (tmp) => { + const assetsDir = path.join(tmp, "assets"); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');"); + await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js")); + resolveControlUiRootSyncMock.mockReturnValue(tmp); + + const { res } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: "/assets/app.hl.js", method: "GET" } as IncomingMessage, + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 4810d987a5fa0..a63bb1590e292 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -45,6 +45,7 @@ describe("handleControlUiHttpRequest", () => { method: "GET" | "HEAD" | "POST"; rootPath: string; basePath?: string; + rootKind?: "resolved" | "bundled"; }) { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( @@ -52,7 +53,7 @@ describe("handleControlUiHttpRequest", () => { res, { ...(params.basePath ? { basePath: params.basePath } : {}), - root: { kind: "resolved", path: params.rootPath }, + root: { kind: params.rootKind ?? "resolved", path: params.rootPath }, }, ); return { res, end, handled }; @@ -326,6 +327,72 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("rejects hardlinked index.html for non-package control-ui roots", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-index-hardlink-")); + try { + const outsideIndex = path.join(outsideDir, "index.html"); + await fs.writeFile(outsideIndex, "outside-hardlink\n"); + await fs.rm(path.join(tmp, "index.html")); + await fs.link(outsideIndex, path.join(tmp, "index.html")); + + const { res, end, handled } = runControlUiRequest({ + url: "/", + method: "GET", + rootPath: tmp, + }); + expectNotFoundResponse({ handled, res, end }); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects hardlinked asset files for custom/resolved roots (security boundary)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const assetsDir = path.join(tmp, "assets"); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');"); + await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js")); + + const { res, end, handled } = runControlUiRequest({ + url: "/assets/app.hl.js", + method: "GET", + rootPath: tmp, + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + expect(end).toHaveBeenCalledWith("Not Found"); + }, + }); + }); + + it("serves hardlinked asset files for bundled roots (pnpm global install)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const assetsDir = path.join(tmp, "assets"); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, "app.js"), "console.log('hi');"); + await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js")); + + const { res, end, handled } = runControlUiRequest({ + url: "/assets/app.hl.js", + method: "GET", + rootPath: tmp, + rootKind: "bundled", + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(String(end.mock.calls[0]?.[0] ?? "")).toBe("console.log('hi');"); + }, + }); + }); + it("does not handle POST to root-mounted paths (plugin webhook passthrough)", async () => { await withControlUiRoot({ fn: async (tmp) => { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 99e1e4e417496..b3d65bd72b8a8 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -3,7 +3,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; -import { resolveControlUiRootSync } from "../infra/control-ui-assets.js"; +import { + isPackageProvenControlUiRootSync, + resolveControlUiRootSync, +} from "../infra/control-ui-assets.js"; import { isWithinDir } from "../infra/path-safety.js"; import { openVerifiedFileSync } from "../infra/safe-open-sync.js"; import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js"; @@ -39,6 +42,7 @@ export type ControlUiRequestOptions = { }; export type ControlUiRootState = + | { kind: "bundled"; path: string } | { kind: "resolved"; path: string } | { kind: "invalid"; path: string } | { kind: "missing" }; @@ -256,6 +260,7 @@ function resolveSafeAvatarFile(filePath: string): { path: string; fd: number } | function resolveSafeControlUiFile( rootReal: string, filePath: string, + rejectHardlinks: boolean, ): { path: string; fd: number } | null { const opened = openBoundaryFileSync({ absolutePath: filePath, @@ -263,6 +268,7 @@ function resolveSafeControlUiFile( rootRealPath: rootReal, boundaryLabel: "control ui root", skipLexicalRootCheck: true, + rejectHardlinks, }); if (!opened.ok) { if (opened.reason === "io") { @@ -367,7 +373,7 @@ export function handleControlUiHttpRequest( } const root = - rootState?.kind === "resolved" + rootState?.kind === "resolved" || rootState?.kind === "bundled" ? rootState.path : resolveControlUiRootSync({ moduleUrl: import.meta.url, @@ -419,7 +425,16 @@ export function handleControlUiHttpRequest( return true; } - const safeFile = resolveSafeControlUiFile(rootReal, filePath); + const isBundledRoot = + rootState?.kind === "bundled" || + (rootState === undefined && + isPackageProvenControlUiRootSync(root, { + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + })); + const rejectHardlinks = !isBundledRoot; + const safeFile = resolveSafeControlUiFile(rootReal, filePath, rejectHardlinks); if (safeFile) { try { if (respondHeadForFile(req, res, safeFile.path)) { @@ -448,7 +463,7 @@ export function handleControlUiHttpRequest( // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); - const safeIndex = resolveSafeControlUiFile(rootReal, indexPath); + const safeIndex = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks); if (safeIndex) { try { if (respondHeadForFile(req, res, safeIndex.path)) { diff --git a/src/gateway/server.control-ui-root.test.ts b/src/gateway/server.control-ui-root.test.ts new file mode 100644 index 0000000000000..bc2e60e3f29a6 --- /dev/null +++ b/src/gateway/server.control-ui-root.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +async function withGlobalControlUiHardlinkFixture(run: (rootPath: string) => Promise) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-ui-hardlink-")); + try { + const packageRoot = path.join(tmp, "pnpm-global", "5", "node_modules", "openclaw"); + const controlUiRoot = path.join(packageRoot, "dist", "control-ui"); + await fs.mkdir(controlUiRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw" }), + ); + + const storeDir = path.join(tmp, "pnpm-store", "files"); + await fs.mkdir(storeDir, { recursive: true }); + const storeIndex = path.join(storeDir, "index.html"); + await fs.writeFile(storeIndex, "pnpm-hardlink-ui\n"); + await fs.link(storeIndex, path.join(controlUiRoot, "index.html")); + + return await run(controlUiRoot); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +} + +describe("gateway.controlUi.root", () => { + test("rejects hardlinked index.html when configured root points at global OpenClaw package control-ui", async () => { + await withGlobalControlUiHardlinkFixture(async (rootPath) => { + testState.gatewayControlUi = { root: rootPath }; + await withGatewayServer( + async ({ port }) => { + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Not Found"); + }, + { serverOptions: { controlUiEnabled: true } }, + ); + }); + }); +}); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 1b2048b93960c..898cdc6fe87fb 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -24,6 +24,7 @@ import { resolveMainSessionKey } from "../config/sessions.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { ensureControlUiAssetsBuilt, + isPackageProvenControlUiRootSync, resolveControlUiRootOverrideSync, resolveControlUiRootSync, } from "../infra/control-ui-assets.js"; @@ -545,7 +546,16 @@ export async function startGatewayServer( }); } controlUiRootState = resolvedRoot - ? { kind: "resolved", path: resolvedRoot } + ? { + kind: isPackageProvenControlUiRootSync(resolvedRoot, { + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }) + ? "bundled" + : "resolved", + path: resolvedRoot, + } : { kind: "missing" }; } diff --git a/src/infra/control-ui-assets.test.ts b/src/infra/control-ui-assets.test.ts index c1c79941e1a3e..ea834d093a8fc 100644 --- a/src/infra/control-ui-assets.test.ts +++ b/src/infra/control-ui-assets.test.ts @@ -76,6 +76,7 @@ vi.mock("./openclaw-root.js", () => ({ let resolveControlUiRepoRoot: typeof import("./control-ui-assets.js").resolveControlUiRepoRoot; let resolveControlUiDistIndexPath: typeof import("./control-ui-assets.js").resolveControlUiDistIndexPath; let resolveControlUiDistIndexHealth: typeof import("./control-ui-assets.js").resolveControlUiDistIndexHealth; +let isPackageProvenControlUiRootSync: typeof import("./control-ui-assets.js").isPackageProvenControlUiRootSync; let resolveControlUiRootOverrideSync: typeof import("./control-ui-assets.js").resolveControlUiRootOverrideSync; let resolveControlUiRootSync: typeof import("./control-ui-assets.js").resolveControlUiRootSync; let openclawRoot: typeof import("./openclaw-root.js"); @@ -86,6 +87,7 @@ describe("control UI assets helpers (fs-mocked)", () => { resolveControlUiRepoRoot, resolveControlUiDistIndexPath, resolveControlUiDistIndexHealth, + isPackageProvenControlUiRootSync, resolveControlUiRootOverrideSync, resolveControlUiRootSync, } = await import("./control-ui-assets.js")); @@ -123,6 +125,18 @@ describe("control UI assets helpers (fs-mocked)", () => { ); }); + it("resolves dist control-ui index path for symlinked argv1 via realpath", async () => { + const pkgRoot = abs("fixtures/bun-global/openclaw"); + const wrapperArgv1 = abs("fixtures/bin/openclaw"); + const realEntrypoint = path.join(pkgRoot, "dist", "index.js"); + + state.realpaths.set(wrapperArgv1, realEntrypoint); + + await expect(resolveControlUiDistIndexPath(wrapperArgv1)).resolves.toBe( + path.join(pkgRoot, "dist", "control-ui", "index.html"), + ); + }); + it("uses resolveOpenClawPackageRoot when available", async () => { const pkgRoot = abs("fixtures/openclaw"); ( @@ -199,4 +213,48 @@ describe("control UI assets helpers (fs-mocked)", () => { const moduleUrl = pathToFileURL(path.join(pkgRoot, "dist", "bundle.js")).toString(); expect(resolveControlUiRootSync({ moduleUrl })).toBe(uiDir); }); + + it("resolves control-ui root for symlinked argv1 via realpath", () => { + const pkgRoot = abs("fixtures/bun-global/openclaw"); + const wrapperArgv1 = abs("fixtures/bin/openclaw"); + const realEntrypoint = path.join(pkgRoot, "dist", "index.js"); + const uiDir = path.join(pkgRoot, "dist", "control-ui"); + + state.realpaths.set(wrapperArgv1, realEntrypoint); + setFile(path.join(uiDir, "index.html"), "\n"); + + expect(resolveControlUiRootSync({ argv1: wrapperArgv1 })).toBe(uiDir); + }); + + it("detects package-proven control-ui roots", () => { + const pkgRoot = abs("fixtures/openclaw-package-root"); + const uiDir = path.join(pkgRoot, "dist", "control-ui"); + setDir(uiDir); + setFile(path.join(uiDir, "index.html"), "\n"); + ( + openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType + ).mockReturnValueOnce(pkgRoot); + + expect( + isPackageProvenControlUiRootSync(uiDir, { + cwd: abs("fixtures/cwd"), + }), + ).toBe(true); + }); + + it("does not treat fallback roots as package-proven", () => { + const pkgRoot = abs("fixtures/openclaw-package-root"); + const fallbackRoot = abs("fixtures/fallback-root/dist/control-ui"); + setDir(fallbackRoot); + setFile(path.join(fallbackRoot, "index.html"), "\n"); + ( + openclawRoot.resolveOpenClawPackageRootSync as unknown as ReturnType + ).mockReturnValueOnce(pkgRoot); + + expect( + isPackageProvenControlUiRootSync(fallbackRoot, { + cwd: abs("fixtures/fallback-root"), + }), + ).toBe(false); + }); }); diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 4091f8b7afb31..90cdd7c31a207 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -79,11 +79,23 @@ export async function resolveControlUiDistIndexPath( return null; } const normalized = path.resolve(argv1); + const entrypointCandidates = [normalized]; + try { + const realpathEntrypoint = fs.realpathSync(normalized); + if (realpathEntrypoint !== normalized) { + entrypointCandidates.push(realpathEntrypoint); + } + } catch { + // Ignore missing/non-realpath argv1 and keep path-based candidates. + } - // Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js) - const distDir = path.dirname(normalized); - if (path.basename(distDir) === "dist") { - return path.join(distDir, "control-ui", "index.html"); + // Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js). + // Include symlink-resolved argv1 so global wrappers (e.g. Bun) still map to dist/control-ui. + for (const entrypoint of entrypointCandidates) { + const distDir = path.dirname(entrypoint); + if (path.basename(distDir) === "dist") { + return path.join(distDir, "control-ui", "index.html"); + } } const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized, moduleUrl }); @@ -93,29 +105,34 @@ export async function resolveControlUiDistIndexPath( // Fallback: traverse up and find package.json with name "openclaw" + dist/control-ui/index.html // This handles global installs where path-based resolution might fail. - let dir = path.dirname(normalized); - for (let i = 0; i < 8; i++) { - const pkgJsonPath = path.join(dir, "package.json"); - const indexPath = path.join(dir, "dist", "control-ui", "index.html"); - if (fs.existsSync(pkgJsonPath)) { - try { - const raw = fs.readFileSync(pkgJsonPath, "utf-8"); - const parsed = JSON.parse(raw) as { name?: unknown }; - if (parsed.name === "openclaw") { - return fs.existsSync(indexPath) ? indexPath : null; + const fallbackStartDirs = new Set( + entrypointCandidates.map((candidate) => path.dirname(candidate)), + ); + for (const startDir of fallbackStartDirs) { + let dir = startDir; + for (let i = 0; i < 8; i++) { + const pkgJsonPath = path.join(dir, "package.json"); + const indexPath = path.join(dir, "dist", "control-ui", "index.html"); + if (fs.existsSync(pkgJsonPath)) { + try { + const raw = fs.readFileSync(pkgJsonPath, "utf-8"); + const parsed = JSON.parse(raw) as { name?: unknown }; + if (parsed.name === "openclaw") { + return fs.existsSync(indexPath) ? indexPath : null; + } + // Stop at the first package boundary to avoid resolving through unrelated ancestors. + break; + } catch { + // Invalid package.json at package boundary; abort this candidate chain. + break; } - // Stop at the first package boundary to avoid resolving through unrelated ancestors. - return null; - } catch { - // Invalid package.json at package boundary; abort fallback resolution. - return null; } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; } return null; @@ -128,6 +145,22 @@ export type ControlUiRootResolveOptions = { execPath?: string; }; +function pathsMatchByRealpathOrResolve(left: string, right: string): boolean { + let realLeft: string; + let realRight: string; + try { + realLeft = fs.realpathSync(left); + } catch { + realLeft = path.resolve(left); + } + try { + realRight = fs.realpathSync(right); + } catch { + realRight = path.resolve(right); + } + return realLeft === realRight; +} + function addCandidate(candidates: Set, value: string | null) { if (!value) { return; @@ -158,6 +191,16 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {}) const cwd = opts.cwd ?? process.cwd(); const moduleDir = opts.moduleUrl ? path.dirname(fileURLToPath(opts.moduleUrl)) : null; const argv1Dir = argv1 ? path.dirname(path.resolve(argv1)) : null; + const argv1RealpathDir = (() => { + if (!argv1) { + return null; + } + try { + return path.dirname(fs.realpathSync(path.resolve(argv1))); + } catch { + return null; + } + })(); const execDir = (() => { try { const execPath = opts.execPath ?? process.execPath; @@ -187,6 +230,11 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {}) addCandidate(candidates, path.join(argv1Dir, "dist", "control-ui")); addCandidate(candidates, path.join(argv1Dir, "control-ui")); } + if (argv1RealpathDir && argv1RealpathDir !== argv1Dir) { + // Symlinked wrappers (e.g. ~/.bun/bin/openclaw -> .../dist/index.js) + addCandidate(candidates, path.join(argv1RealpathDir, "dist", "control-ui")); + addCandidate(candidates, path.join(argv1RealpathDir, "control-ui")); + } if (packageRoot) { addCandidate(candidates, path.join(packageRoot, "dist", "control-ui")); } @@ -201,6 +249,24 @@ export function resolveControlUiRootSync(opts: ControlUiRootResolveOptions = {}) return null; } +export function isPackageProvenControlUiRootSync( + root: string, + opts: ControlUiRootResolveOptions = {}, +): boolean { + const argv1 = opts.argv1 ?? process.argv[1]; + const cwd = opts.cwd ?? process.cwd(); + const packageRoot = resolveOpenClawPackageRootSync({ + argv1, + moduleUrl: opts.moduleUrl, + cwd, + }); + if (!packageRoot) { + return false; + } + const packageDistRoot = path.join(packageRoot, "dist", "control-ui"); + return pathsMatchByRealpathOrResolve(root, packageDistRoot); +} + export type EnsureControlUiAssetsResult = { ok: boolean; built: boolean; From 6d5e142b93120f097cd2afc2467547754b836753 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 17:57:46 -0700 Subject: [PATCH 198/311] Docker: improve build cache reuse (#40351) * Docker: improve build cache reuse * Tests: cover Docker build cache layout * Docker: fix sandbox cache mount continuations * Docker: document qr-import manifest scope * Docker: narrow e2e install inputs * CI: cache Docker builds in workflows * CI: route sandbox smoke through setup script * CI: keep sandbox smoke on script path --- .github/workflows/docker-release.yml | 8 ++ Dockerfile | 38 +++--- Dockerfile.sandbox | 9 +- Dockerfile.sandbox-browser | 9 +- Dockerfile.sandbox-common | 10 +- scripts/docker/cleanup-smoke/Dockerfile | 12 +- scripts/docker/install-sh-e2e/Dockerfile | 9 +- scripts/docker/install-sh-nonroot/Dockerfile | 9 +- scripts/docker/install-sh-smoke/Dockerfile | 9 +- scripts/e2e/Dockerfile | 14 +- scripts/e2e/Dockerfile.qr-import | 14 +- scripts/sandbox-common-setup.sh | 16 ++- src/docker-build-cache.test.ts | 127 +++++++++++++++++++ 13 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 src/docker-build-cache.test.ts diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 2cc29748c9143..f991b7f86534e 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -109,6 +109,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-amd64 + cache-to: type=gha,mode=max,scope=docker-release-amd64 - name: Build and push amd64 slim image id: build-slim @@ -122,6 +124,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-amd64 + cache-to: type=gha,mode=max,scope=docker-release-amd64 # Build arm64 images (default + slim share the build stage cache) build-arm64: @@ -210,6 +214,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-arm64 + cache-to: type=gha,mode=max,scope=docker-release-arm64 - name: Build and push arm64 slim image id: build-slim @@ -223,6 +229,8 @@ jobs: labels: ${{ steps.labels.outputs.value }} provenance: false push: true + cache-from: type=gha,scope=docker-release-arm64 + cache-to: type=gha,mode=max,scope=docker-release-arm64 # Create multi-platform manifests create-manifest: diff --git a/Dockerfile b/Dockerfile index f1d7163d1920e..d6923365b4be6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + # Opt-in extension dependencies at build time (space-separated directory names). # Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" . # @@ -48,13 +50,13 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY ui/package.json ./ui/package.json COPY patches ./patches -COPY scripts ./scripts COPY --from=ext-deps /out/ ./extensions/ # Reduce OOM risk on low-memory hosts during dependency installation. # Docker builds on small VMs may otherwise fail with "Killed" (exit 137). -RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile COPY . . @@ -117,11 +119,11 @@ WORKDIR /app # Install system utilities present in bookworm but missing in bookworm-slim. # On the full bookworm image these are already installed (apt-get is a no-op). -RUN apt-get update && \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - procps hostname curl git openssl && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + procps hostname curl git openssl RUN chown node:node /app @@ -145,11 +147,11 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \ # Install additional system packages needed by your skills or extensions. # Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . ARG OPENCLAW_DOCKER_APT_PACKAGES="" -RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \ fi # Optionally install Chromium and Xvfb for browser automation. @@ -157,15 +159,15 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ # Adds ~300MB but eliminates the 60-90s Playwright install on every container start. # Must run after node_modules COPY so playwright-core is available. ARG OPENCLAW_INSTALL_BROWSER="" -RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \ mkdir -p /home/node/.cache/ms-playwright && \ PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \ node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \ - chown -R node:node /home/node/.cache/ms-playwright && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + chown -R node:node /home/node/.cache/ms-playwright; \ fi # Optionally install Docker CLI for sandbox container management. @@ -174,7 +176,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ # Required for agents.defaults.sandbox to function in Docker deployments. ARG OPENCLAW_INSTALL_DOCKER_CLI="" ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88" -RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ +RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates curl gnupg && \ @@ -195,9 +199,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ "$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - docker-ce-cli docker-compose-plugin && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + docker-ce-cli docker-compose-plugin; \ fi # Expose the CLI binary without requiring npm global writes as non-root. diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index a463d4a102010..8b50c7a67451f 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -1,8 +1,12 @@ +# syntax=docker/dockerfile:1.7 + FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ @@ -10,8 +14,7 @@ RUN apt-get update \ git \ jq \ python3 \ - ripgrep \ - && rm -rf /var/lib/apt/lists/* + ripgrep RUN useradd --create-home --shell /bin/bash sandbox USER sandbox diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index 78b0de98904b5..f04e4a82a62ec 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -1,8 +1,12 @@ +# syntax=docker/dockerfile:1.7 + FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ @@ -17,8 +21,7 @@ RUN apt-get update \ socat \ websockify \ x11vnc \ - xvfb \ - && rm -rf /var/lib/apt/lists/* + xvfb COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common index 71f80070adf0c..39eaa3692b4a3 100644 --- a/Dockerfile.sandbox-common +++ b/Dockerfile.sandbox-common @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim FROM ${BASE_IMAGE} @@ -19,9 +21,10 @@ ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH} -RUN apt-get update \ - && apt-get install -y --no-install-recommends ${PACKAGES} \ - && rm -rf /var/lib/apt/lists/* +RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends ${PACKAGES} RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi @@ -42,4 +45,3 @@ fi # Default is sandbox, but allow BASE_IMAGE overrides to select another final user. USER ${FINAL_USER} - diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 34ce3327ac7b9..e67a4b1fe87a1 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -1,15 +1,19 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45 -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ - git \ - && rm -rf /var/lib/apt/lists/* + git WORKDIR /repo COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN corepack enable \ +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + corepack enable \ && pnpm install --frozen-lockfile COPY . . diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index 839d637a04b49..05b77f45197d6 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -1,12 +1,15 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45 -RUN apt-get update \ +RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \ + apt-get update \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ curl \ - git \ - && rm -rf /var/lib/apt/lists/* + git COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 9b7912323a440..d0c085d9f6946 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -1,6 +1,10 @@ +# syntax=docker/dockerfile:1.7 + FROM ubuntu:24.04@sha256:cd1dba651b3080c3686ecf4e3c4220f026b521fb76978881737d24f200828b2b -RUN set -eux; \ +RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \ + set -eux; \ for attempt in 1 2 3; do \ if apt-get update -o Acquire::Retries=3; then break; fi; \ echo "apt-get update failed (attempt ${attempt})" >&2; \ @@ -14,8 +18,7 @@ RUN set -eux; \ g++ \ make \ python3 \ - sudo \ - && rm -rf /var/lib/apt/lists/* + sudo RUN useradd -m -s /bin/bash app \ && echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index eb2dcfe522622..94fdca13a310a 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -1,6 +1,10 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45 -RUN set -eux; \ +RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ + set -eux; \ for attempt in 1 2 3; do \ if apt-get update -o Acquire::Retries=3; then break; fi; \ echo "apt-get update failed (attempt ${attempt})" >&2; \ @@ -15,8 +19,7 @@ RUN set -eux; \ g++ \ make \ python3 \ - sudo \ - && rm -rf /var/lib/apt/lists/* + sudo COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 9936acec8a72a..e8bd039155de6 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 RUN corepack enable @@ -6,20 +8,26 @@ WORKDIR /app ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning" -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY ui/package.json ./ui/package.json +COPY extensions/memory-core/package.json ./extensions/memory-core/package.json +COPY patches ./patches + +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm install --frozen-lockfile + +COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./ COPY src ./src COPY test ./test COPY scripts ./scripts COPY docs ./docs COPY skills ./skills -COPY patches ./patches COPY ui ./ui COPY extensions/memory-core ./extensions/memory-core COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI -RUN pnpm install --frozen-lockfile RUN pnpm build RUN pnpm ui:build diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import index f97d57891fdd5..e221e0278a9c1 100644 --- a/scripts/e2e/Dockerfile.qr-import +++ b/scripts/e2e/Dockerfile.qr-import @@ -1,12 +1,22 @@ +# syntax=docker/dockerfile:1.7 + FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 RUN corepack enable WORKDIR /app -COPY . . +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY ui/package.json ./ui/package.json +COPY patches ./patches -RUN pnpm install --frozen-lockfile +# This image only exercises the root qrcode-terminal dependency path. +# Keep the pre-install copy set limited to the manifests needed for root +# workspace resolution so unrelated extension edits do not bust the layer. +RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm install --frozen-lockfile + +COPY . . RUN useradd --create-home --shell /bin/bash appuser \ && chown -R appuser:appuser /app diff --git a/scripts/sandbox-common-setup.sh b/scripts/sandbox-common-setup.sh index 95c90c8cb974e..258ed19bcae56 100755 --- a/scripts/sandbox-common-setup.sh +++ b/scripts/sandbox-common-setup.sh @@ -10,6 +10,9 @@ BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}" INSTALL_BREW="${INSTALL_BREW:-1}" BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}" FINAL_USER="${FINAL_USER:-sandbox}" +OPENCLAW_DOCKER_BUILD_USE_BUILDX="${OPENCLAW_DOCKER_BUILD_USE_BUILDX:-0}" +OPENCLAW_DOCKER_BUILD_CACHE_FROM="${OPENCLAW_DOCKER_BUILD_CACHE_FROM:-}" +OPENCLAW_DOCKER_BUILD_CACHE_TO="${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}" if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then echo "Base image missing: ${BASE_IMAGE}" @@ -19,7 +22,18 @@ fi echo "Building ${TARGET_IMAGE} with: ${PACKAGES}" -docker build \ +build_cmd=(docker build) +if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX}" = "1" ]; then + build_cmd=(docker buildx build --load) + if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}" ]; then + build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}") + fi + if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO}" ]; then + build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}") + fi +fi + +"${build_cmd[@]}" \ -t "${TARGET_IMAGE}" \ -f Dockerfile.sandbox-common \ --build-arg BASE_IMAGE="${BASE_IMAGE}" \ diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts new file mode 100644 index 0000000000000..6f56ef4f5c702 --- /dev/null +++ b/src/docker-build-cache.test.ts @@ -0,0 +1,127 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); + +async function readRepoFile(path: string): Promise { + return readFile(resolve(repoRoot, path), "utf8"); +} + +describe("docker build cache layout", () => { + it("keeps the root dependency layer independent from scripts changes", async () => { + const dockerfile = await readRepoFile("Dockerfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + const copyAllIndex = dockerfile.indexOf("COPY . ."); + const scriptsCopyIndex = dockerfile.indexOf("COPY scripts ./scripts"); + + expect(installIndex).toBeGreaterThan(-1); + expect(copyAllIndex).toBeGreaterThan(installIndex); + expect(scriptsCopyIndex === -1 || scriptsCopyIndex > installIndex).toBe(true); + }); + + it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => { + for (const path of [ + "Dockerfile", + "scripts/e2e/Dockerfile", + "scripts/e2e/Dockerfile.qr-import", + "scripts/docker/cleanup-smoke/Dockerfile", + ]) { + const dockerfile = await readRepoFile(path); + expect(dockerfile, `${path} should use a shared pnpm store cache`).toContain( + "--mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked", + ); + } + }); + + it("uses apt cache mounts in Dockerfiles that install system packages", async () => { + for (const path of [ + "Dockerfile", + "Dockerfile.sandbox", + "Dockerfile.sandbox-browser", + "Dockerfile.sandbox-common", + "scripts/docker/cleanup-smoke/Dockerfile", + "scripts/docker/install-sh-smoke/Dockerfile", + "scripts/docker/install-sh-e2e/Dockerfile", + "scripts/docker/install-sh-nonroot/Dockerfile", + ]) { + const dockerfile = await readRepoFile(path); + expect(dockerfile, `${path} should cache apt package archives`).toContain( + "target=/var/cache/apt,sharing=locked", + ); + expect(dockerfile, `${path} should cache apt metadata`).toContain( + "target=/var/lib/apt,sharing=locked", + ); + } + }); + + it("does not leave empty shell continuation lines in sandbox-common", async () => { + const dockerfile = await readRepoFile("Dockerfile.sandbox-common"); + expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\"); + expect(dockerfile).toContain( + 'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi', + ); + }); + + it("does not leave blank lines after shell continuation markers", async () => { + for (const path of [ + "Dockerfile.sandbox", + "Dockerfile.sandbox-browser", + "Dockerfile.sandbox-common", + "scripts/docker/cleanup-smoke/Dockerfile", + "scripts/docker/install-sh-smoke/Dockerfile", + "scripts/docker/install-sh-e2e/Dockerfile", + "scripts/docker/install-sh-nonroot/Dockerfile", + ]) { + const dockerfile = await readRepoFile(path); + expect( + dockerfile, + `${path} should not have blank lines after a trailing backslash`, + ).not.toMatch(/\\\n\s*\n/); + } + }); + + it("copies only install inputs before pnpm install in the e2e image", async () => { + const dockerfile = await readRepoFile("scripts/e2e/Dockerfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + + expect( + dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), + ).toBeLessThan(installIndex); + expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); + expect( + dockerfile.indexOf( + "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + ), + ).toBeLessThan(installIndex); + expect( + dockerfile.indexOf( + "COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./", + ), + ).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY src ./src")).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY test ./test")).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY scripts ./scripts")).toBeGreaterThan(installIndex); + expect(dockerfile.indexOf("COPY ui ./ui")).toBeGreaterThan(installIndex); + }); + + it("copies manifests before install in the qr-import image", async () => { + const dockerfile = await readRepoFile("scripts/e2e/Dockerfile.qr-import"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + + expect( + dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"), + ).toBeLessThan(installIndex); + expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex); + expect(dockerfile).toContain( + "This image only exercises the root qrcode-terminal dependency path.", + ); + expect( + dockerfile.indexOf( + "COPY extensions/memory-core/package.json ./extensions/memory-core/package.json", + ), + ).toBe(-1); + expect(dockerfile.indexOf("COPY . .")).toBeGreaterThan(installIndex); + }); +}); From eabda6e3a40186fb7e700e6bdf518418863a52dc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 18:13:35 -0700 Subject: [PATCH 199/311] fix(tests): correct security check failure --- .secrets.baseline | 8 ++++---- src/secrets/runtime.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 2f794ecc01bd2..4f591aa13ef05 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -227,7 +227,7 @@ "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1749 + "line_number": 1763 } ], "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ @@ -288,7 +288,7 @@ "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1749 + "line_number": 1763 } ], "docs/.i18n/zh-CN.tm.jsonl": [ @@ -11584,7 +11584,7 @@ "filename": "src/agents/pi-embedded-runner/model.ts", "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", "is_verified": false, - "line_number": 267 + "line_number": 279 } ], "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ @@ -13035,5 +13035,5 @@ } ] }, - "generated_at": "2026-03-08T20:41:38Z" + "generated_at": "2026-03-09T01:11:58Z" } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 02b5f84f9a6f8..00524153b5fa6 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -617,7 +617,7 @@ describe("secrets runtime snapshot", () => { }); await fs.writeFile( secretFile, - `${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, + `${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, // pragma: allowlist secret { encoding: "utf8", mode: 0o600 }, ); await fs.writeFile( From b34158086a4868947a6614003528ac2697cec84e Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 9 Mar 2026 02:18:30 +0100 Subject: [PATCH 200/311] docs(changelog): correct Control UI contributor credit (#40420) Merged via squash. Prepared head SHA: e4295fe18b05aee751b2e86579348c14db7c9d55 Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be20082a5fbd..a05934e5fd17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,8 @@ Docs: https://docs.openclaw.ai - TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. - Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232) - Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending. -- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @velvet-shark. +- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord. +- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark. ## 2026.3.7 From f6243916b51ca4b4131674fa2f6fa9d863314c01 Mon Sep 17 00:00:00 2001 From: yuweuii <82372187+yuweuii@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:23:49 +0800 Subject: [PATCH 201/311] fix(models): use 1M context for openai-codex gpt-5.4 (#37876) Merged via squash. Prepared head SHA: c41020779ee4f5a77cab932b5fcfb973d3e6a53d Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/agents/model-compat.test.ts | 2 +- src/agents/model-forward-compat.ts | 12 +++++++++-- .../pi-embedded-runner/model.test-harness.ts | 3 ++- src/cli/daemon-cli/lifecycle.test.ts | 21 ++++++++++--------- .../list.list-command.forward-compat.test.ts | 2 +- 6 files changed, 26 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05934e5fd17f..1610b81e2ef32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending. - Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord. - Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark. +- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii. ## 2026.3.7 diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 24361c0a53445..3c1894bb39020 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -363,7 +363,7 @@ describe("resolveForwardCompatModel", () => { expectResolvedForwardCompat(model, { provider: "openai-codex", id: "gpt-5.4" }); expect(model?.api).toBe("openai-codex-responses"); expect(model?.baseUrl).toBe("https://chatgpt.com/backend-api"); - expect(model?.contextWindow).toBe(272_000); + expect(model?.contextWindow).toBe(1_050_000); expect(model?.maxTokens).toBe(128_000); }); diff --git a/src/agents/model-forward-compat.ts b/src/agents/model-forward-compat.ts index e27260db8329e..8735193346ed4 100644 --- a/src/agents/model-forward-compat.ts +++ b/src/agents/model-forward-compat.ts @@ -12,6 +12,8 @@ const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const; const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const; const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; +const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000; +const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000; const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const; const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex"; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; @@ -123,9 +125,14 @@ function resolveOpenAICodexForwardCompatModel( let templateIds: readonly string[]; let eligibleProviders: Set; + let patch: Partial> | undefined; if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) { templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS; eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS; + patch = { + contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS, + maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS, + }; } else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) { templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS; eligibleProviders = CODEX_GPT53_ELIGIBLE_PROVIDERS; @@ -146,6 +153,7 @@ function resolveOpenAICodexForwardCompatModel( ...template, id: trimmedModelId, name: trimmedModelId, + ...patch, } as Model); } @@ -158,8 +166,8 @@ function resolveOpenAICodexForwardCompatModel( reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: DEFAULT_CONTEXT_TOKENS, - maxTokens: DEFAULT_CONTEXT_TOKENS, + contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, } as Model); } diff --git a/src/agents/pi-embedded-runner/model.test-harness.ts b/src/agents/pi-embedded-runner/model.test-harness.ts index c28210b19212d..58d724307de6a 100644 --- a/src/agents/pi-embedded-runner/model.test-harness.ts +++ b/src/agents/pi-embedded-runner/model.test-harness.ts @@ -36,13 +36,14 @@ export function mockOpenAICodexTemplateModel(): void { export function buildOpenAICodexForwardCompatExpectation( id: string = "gpt-5.3-codex", ): Partial & { provider: string; id: string } { + const isGpt54 = id === "gpt-5.4"; return { provider: "openai-codex", id, api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api", reasoning: true, - contextWindow: 272000, + contextWindow: isGpt54 ? 1_050_000 : 272000, maxTokens: 128000, }; } diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index f1e87fc4938c0..3f0ed6d531c30 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> ->(); +const probeGateway = + vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> + >(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 9dd7f55f7976c..eafe6a1cb0122 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -7,7 +7,7 @@ const OPENAI_CODEX_MODEL = { api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api", input: ["text"], - contextWindow: 272000, + contextWindow: 1_050_000, maxTokens: 128000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }; From 4d501e4ccf2f38e722e6ec5e2adf0f9b733e05e7 Mon Sep 17 00:00:00 2001 From: Tyson Cung <45380903+tysoncung@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:59:21 +0800 Subject: [PATCH 202/311] fix(telegram): add download timeout to prevent polling loop hang (#40098) Merged via squash. Prepared head SHA: abdfa1a35f41615aaa52e06b5ba9581eeb8f8a6a Co-authored-by: tysoncung <45380903+tysoncung@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/cli/daemon-cli/lifecycle.test.ts | 21 ++++--- src/media/fetch.test.ts | 33 +++++++++++ src/media/fetch.ts | 33 ++++++++--- src/media/read-response-with-limit.test.ts | 66 ++++++++++++++++++++++ src/media/read-response-with-limit.ts | 45 ++++++++++++++- src/telegram/bot/delivery.resolve-media.ts | 4 ++ 7 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 src/media/read-response-with-limit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1610b81e2ef32..dbed37c76434e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord. - Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark. - Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii. +- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung. ## 2026.3.7 diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 3f0ed6d531c30..f1e87fc4938c0 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]) const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]); const resolveGatewayPort = vi.fn(() => 18789); const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []); -const probeGateway = - vi.fn< - (opts: { - url: string; - auth?: { token?: string; password?: string }; - timeoutMs: number; - }) => Promise<{ - ok: boolean; - configSnapshot: unknown; - }> - >(); +const probeGateway = vi.fn< + (opts: { + url: string; + auth?: { token?: string; password?: string }; + timeoutMs: number; + }) => Promise<{ + ok: boolean; + configSnapshot: unknown; + }> +>(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.fn(() => ({})); diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index 4802d6b3019f0..00966e26a34f1 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -12,6 +12,19 @@ function makeStream(chunks: Uint8Array[]) { }); } +function makeStallingFetch(firstChunk: Uint8Array) { + return vi.fn(async () => { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(firstChunk); + }, + }), + { status: 200 }, + ); + }); +} + describe("fetchRemoteMedia", () => { type LookupFn = NonNullable[0]["lookupFn"]>; @@ -54,6 +67,26 @@ describe("fetchRemoteMedia", () => { ).rejects.toThrow("exceeds maxBytes"); }); + it("aborts stalled body reads when idle timeout expires", async () => { + const lookupFn = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + ]) as unknown as LookupFn; + const fetchImpl = makeStallingFetch(new Uint8Array([1, 2])); + + await expect( + fetchRemoteMedia({ + url: "https://example.com/file.bin", + fetchImpl, + lookupFn, + maxBytes: 1024, + readIdleTimeoutMs: 20, + }), + ).rejects.toMatchObject({ + code: "fetch_failed", + name: "MediaFetchError", + }); + }, 5_000); + it("blocks private IP literals before fetching", async () => { const fetchImpl = vi.fn(); await expect( diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 3f2372c0abf0c..cdd62e4a04437 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -31,6 +31,8 @@ type FetchMediaOptions = { filePathHint?: string; maxBytes?: number; maxRedirects?: number; + /** Abort if the response body stops yielding data for this long (ms). */ + readIdleTimeoutMs?: number; ssrfPolicy?: SsrFPolicy; lookupFn?: LookupFn; }; @@ -87,6 +89,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise - new MediaFetchError( - "max_bytes", - `Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`, - ), - }) - : Buffer.from(await res.arrayBuffer()); + let buffer: Buffer; + try { + buffer = maxBytes + ? await readResponseWithLimit(res, maxBytes, { + onOverflow: ({ maxBytes, res }) => + new MediaFetchError( + "max_bytes", + `Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`, + ), + chunkTimeoutMs: readIdleTimeoutMs, + }) + : Buffer.from(await res.arrayBuffer()); + } catch (err) { + if (err instanceof MediaFetchError) { + throw err; + } + throw new MediaFetchError( + "fetch_failed", + `Failed to fetch media from ${res.url || url}: ${String(err)}`, + ); + } let fileNameFromUrl: string | undefined; try { const parsed = new URL(finalUrl); diff --git a/src/media/read-response-with-limit.test.ts b/src/media/read-response-with-limit.test.ts new file mode 100644 index 0000000000000..c4cdcfc4fb3ce --- /dev/null +++ b/src/media/read-response-with-limit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { readResponseWithLimit } from "./read-response-with-limit.js"; + +function makeStream(chunks: Uint8Array[], delayMs?: number) { + return new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + if (delayMs) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + controller.enqueue(chunk); + } + controller.close(); + }, + }); +} + +function makeStallingStream(initialChunks: Uint8Array[]) { + return new ReadableStream({ + start(controller) { + for (const chunk of initialChunks) { + controller.enqueue(chunk); + } + }, + }); +} + +describe("readResponseWithLimit", () => { + it("reads all chunks within the limit", async () => { + const body = makeStream([new Uint8Array([1, 2]), new Uint8Array([3, 4])]); + const res = new Response(body); + const buf = await readResponseWithLimit(res, 100); + expect(buf).toEqual(Buffer.from([1, 2, 3, 4])); + }); + + it("throws when total exceeds maxBytes", async () => { + const body = makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]); + const res = new Response(body); + await expect(readResponseWithLimit(res, 4)).rejects.toThrow(/too large/i); + }); + + it("calls custom onOverflow", async () => { + const body = makeStream([new Uint8Array(10)]); + const res = new Response(body); + await expect( + readResponseWithLimit(res, 5, { + onOverflow: ({ size, maxBytes }) => new Error(`custom: ${size} > ${maxBytes}`), + }), + ).rejects.toThrow("custom: 10 > 5"); + }); + + it("times out when no new chunk arrives before idle timeout", async () => { + const body = makeStallingStream([new Uint8Array([1, 2])]); + const res = new Response(body); + await expect(readResponseWithLimit(res, 1024, { chunkTimeoutMs: 50 })).rejects.toThrow( + /stalled/i, + ); + }, 5_000); + + it("does not time out while chunks keep arriving", async () => { + const body = makeStream([new Uint8Array([1]), new Uint8Array([2])], 10); + const res = new Response(body); + const buf = await readResponseWithLimit(res, 100, { chunkTimeoutMs: 500 }); + expect(buf).toEqual(Buffer.from([1, 2])); + }); +}); diff --git a/src/media/read-response-with-limit.ts b/src/media/read-response-with-limit.ts index a9ad353f5eadb..c9ac52c8035a7 100644 --- a/src/media/read-response-with-limit.ts +++ b/src/media/read-response-with-limit.ts @@ -1,14 +1,55 @@ +async function readChunkWithIdleTimeout( + reader: ReadableStreamDefaultReader, + chunkTimeoutMs: number, +): Promise> { + let timeoutId: ReturnType | undefined; + let timedOut = false; + + return await new Promise((resolve, reject) => { + const clear = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + timeoutId = setTimeout(() => { + timedOut = true; + clear(); + void reader.cancel().catch(() => undefined); + reject(new Error(`Media download stalled: no data received for ${chunkTimeoutMs}ms`)); + }, chunkTimeoutMs); + + void reader.read().then( + (result) => { + clear(); + if (!timedOut) { + resolve(result); + } + }, + (err) => { + clear(); + if (!timedOut) { + reject(err); + } + }, + ); + }); +} + export async function readResponseWithLimit( res: Response, maxBytes: number, opts?: { onOverflow?: (params: { size: number; maxBytes: number; res: Response }) => Error; + chunkTimeoutMs?: number; }, ): Promise { const onOverflow = opts?.onOverflow ?? ((params: { size: number; maxBytes: number }) => new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`)); + const chunkTimeoutMs = opts?.chunkTimeoutMs; const body = res.body; if (!body || typeof body.getReader !== "function") { @@ -24,7 +65,9 @@ export async function readResponseWithLimit( let total = 0; try { while (true) { - const { done, value } = await reader.read(); + const { done, value } = chunkTimeoutMs + ? await readChunkWithIdleTimeout(reader, chunkTimeoutMs) + : await reader.read(); if (done) { break; } diff --git a/src/telegram/bot/delivery.resolve-media.ts b/src/telegram/bot/delivery.resolve-media.ts index e0f8d46abbd5e..14df1d6e2a84d 100644 --- a/src/telegram/bot/delivery.resolve-media.ts +++ b/src/telegram/bot/delivery.resolve-media.ts @@ -100,6 +100,9 @@ function resolveRequiredFetchImpl(proxyFetch?: typeof fetch): typeof fetch { return fetchImpl; } +/** Default idle timeout for Telegram media downloads (30 seconds). */ +const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; + async function downloadAndSaveTelegramFile(params: { filePath: string; token: string; @@ -113,6 +116,7 @@ async function downloadAndSaveTelegramFile(params: { fetchImpl: params.fetchImpl, filePathHint: params.filePath, maxBytes: params.maxBytes, + readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS, ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, }); const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath; From e3df94365b0a0a61e8503fd45c2d971dfdf4d2a1 Mon Sep 17 00:00:00 2001 From: Mariano Date: Mon, 9 Mar 2026 04:19:03 +0100 Subject: [PATCH 203/311] ACP: add optional ingress provenance receipts (#40473) Merged via squash. Prepared head SHA: b63e46dd94479de611dab68868340aa18bdaff2f Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs/cli/acp.md | 46 +++++++ src/acp/server.ts | 12 +- src/acp/translator.prompt-prefix.test.ts | 113 ++++++++++++++++++ src/acp/translator.ts | 40 +++++++ src/acp/types.ts | 17 +++ src/auto-reply/reply/agent-runner-utils.ts | 1 + src/auto-reply/reply/get-reply-run.ts | 1 + src/auto-reply/reply/queue/types.ts | 2 + src/auto-reply/templating.ts | 3 + src/cli/acp-cli.ts | 7 ++ src/gateway/protocol/schema/agent.ts | 1 + src/gateway/protocol/schema/logs-chat.ts | 14 +++ .../chat.directive-tags.test.ts | 83 ++++++++++++- src/gateway/server-methods/chat.ts | 69 ++++++++++- src/sessions/input-provenance.ts | 2 + 16 files changed, 406 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbed37c76434e..437757f61a607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. - CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. - CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. +- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky. ### Breaking diff --git a/docs/cli/acp.md b/docs/cli/acp.md index e1fdcf6a39877..7650390ed5554 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -96,6 +96,52 @@ Each ACP session maps to a single Gateway session key. One agent can have many sessions; ACP defaults to an isolated `acp:` session unless you override the key or label. +## Use from `acpx` (Codex, Claude, other ACP clients) + +If you want a coding agent such as Codex or Claude Code to talk to your +OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target. + +Typical flow: + +1. Run the Gateway and make sure the ACP bridge can reach it. +2. Point `acpx openclaw` at `openclaw acp`. +3. Target the OpenClaw session key you want the coding agent to use. + +Examples: + +```bash +# One-shot request into your default OpenClaw ACP session +acpx openclaw exec "Summarize the active OpenClaw session state." + +# Persistent named session for follow-up turns +acpx openclaw sessions ensure --name codex-bridge +acpx openclaw -s codex-bridge --cwd /path/to/repo \ + "Ask my OpenClaw work agent for recent context relevant to this repo." +``` + +If you want `acpx openclaw` to target a specific Gateway and session key every +time, override the `openclaw` agent command in `~/.acpx/config.json`: + +```json +{ + "agents": { + "openclaw": { + "command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main" + } + } +} +``` + +For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the +dev runner so the ACP stream stays clean. For example: + +```bash +env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ... +``` + +This is the easiest way to let Codex, Claude Code, or another ACP-aware client +pull contextual information from an OpenClaw agent without scraping a terminal. + ## Zed editor setup Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI): diff --git a/src/acp/server.ts b/src/acp/server.ts index c65dbad202add..c19f48b363198 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { readSecretFromFile } from "./secret-file.js"; import { AcpGatewayAgent } from "./translator.js"; -import type { AcpServerOptions } from "./types.js"; +import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js"; export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { const cfg = loadConfig(); @@ -186,6 +186,15 @@ function parseArgs(args: string[]): AcpServerOptions { opts.prefixCwd = false; continue; } + if (arg === "--provenance") { + const provenanceMode = normalizeAcpProvenanceMode(args[i + 1]); + if (!provenanceMode) { + throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt."); + } + opts.provenanceMode = provenanceMode; + i += 1; + continue; + } if (arg === "--verbose" || arg === "-v") { opts.verbose = true; continue; @@ -226,6 +235,7 @@ Options: --require-existing Fail if the session key/label does not exist --reset-session Reset the session key before first use --no-prefix-cwd Do not prefix prompts with the working directory + --provenance ACP provenance mode: off, meta, or meta+receipt --verbose, -v Verbose logging to stderr --help, -h Show this help message `); diff --git a/src/acp/translator.prompt-prefix.test.ts b/src/acp/translator.prompt-prefix.test.ts index f6d2b93d263fd..38c186519c01e 100644 --- a/src/acp/translator.prompt-prefix.test.ts +++ b/src/acp/translator.prompt-prefix.test.ts @@ -81,4 +81,117 @@ describe("acp prompt cwd prefix", () => { { expectFinal: true }, ); }); + + it("injects system provenance metadata when enabled", async () => { + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + cwd: path.join(os.homedir(), "openclaw-test"), + }); + + const requestSpy = vi.fn(async (method: string) => { + if (method === "chat.send") { + throw new Error("stop-after-send"); + } + return {}; + }); + const agent = new AcpGatewayAgent( + createAcpConnection(), + createAcpGateway(requestSpy as unknown as GatewayClient["request"]), + { + sessionStore, + provenanceMode: "meta", + }, + ); + + await expect( + agent.prompt({ + sessionId: "session-1", + prompt: [{ type: "text", text: "hello" }], + _meta: {}, + } as unknown as PromptRequest), + ).rejects.toThrow("stop-after-send"); + + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemInputProvenance: { + kind: "external_user", + originSessionId: "session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }, + systemProvenanceReceipt: undefined, + }), + { expectFinal: true }, + ); + }); + + it("injects a system provenance receipt when requested", async () => { + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + cwd: path.join(os.homedir(), "openclaw-test"), + }); + + const requestSpy = vi.fn(async (method: string) => { + if (method === "chat.send") { + throw new Error("stop-after-send"); + } + return {}; + }); + const agent = new AcpGatewayAgent( + createAcpConnection(), + createAcpGateway(requestSpy as unknown as GatewayClient["request"]), + { + sessionStore, + provenanceMode: "meta+receipt", + }, + ); + + await expect( + agent.prompt({ + sessionId: "session-1", + prompt: [{ type: "text", text: "hello" }], + _meta: {}, + } as unknown as PromptRequest), + ).rejects.toThrow("stop-after-send"); + + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemInputProvenance: { + kind: "external_user", + originSessionId: "session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }, + systemProvenanceReceipt: expect.stringContaining("[Source Receipt]"), + }), + { expectFinal: true }, + ); + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemProvenanceReceipt: expect.stringContaining("bridge=openclaw-acp"), + }), + { expectFinal: true }, + ); + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemProvenanceReceipt: expect.stringContaining("originSessionId=session-1"), + }), + { expectFinal: true }, + ); + expect(requestSpy).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + systemProvenanceReceipt: expect.stringContaining("targetSession=agent:main:main"), + }), + { expectFinal: true }, + ); + }); }); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index c7cf3739a9a1d..0dbf3099c3dc1 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import os from "node:os"; import type { Agent, AgentSideConnection, @@ -61,6 +62,32 @@ type AcpGatewayAgentOptions = AcpServerOptions & { const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; +function buildSystemInputProvenance(originSessionId: string) { + return { + kind: "external_user" as const, + originSessionId, + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }; +} + +function buildSystemProvenanceReceipt(params: { + cwd: string; + sessionId: string; + sessionKey: string; +}) { + return [ + "[Source Receipt]", + "bridge=openclaw-acp", + `originHost=${os.hostname()}`, + `originCwd=${shortenHomePath(params.cwd)}`, + `acpSessionId=${params.sessionId}`, + `originSessionId=${params.sessionId}`, + `targetSession=${params.sessionKey}`, + "[/Source Receipt]", + ].join("\n"); +} + export class AcpGatewayAgent implements Agent { private connection: AgentSideConnection; private gateway: GatewayClient; @@ -251,6 +278,17 @@ export class AcpGatewayAgent implements Agent { const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true; const displayCwd = shortenHomePath(session.cwd); const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText; + const provenanceMode = this.opts.provenanceMode ?? "off"; + const systemInputProvenance = + provenanceMode === "off" ? undefined : buildSystemInputProvenance(params.sessionId); + const systemProvenanceReceipt = + provenanceMode === "meta+receipt" + ? buildSystemProvenanceReceipt({ + cwd: session.cwd, + sessionId: params.sessionId, + sessionKey: session.sessionKey, + }) + : undefined; // Defense-in-depth: also check the final assembled message (includes cwd prefix) if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) { @@ -281,6 +319,8 @@ export class AcpGatewayAgent implements Agent { thinking: readString(params._meta, ["thinking", "thinkingLevel"]), deliver: readBool(params._meta, ["deliver"]), timeoutMs: readNumber(params._meta, ["timeoutMs"]), + systemInputProvenance, + systemProvenanceReceipt, }, { expectFinal: true }, ) diff --git a/src/acp/types.ts b/src/acp/types.ts index b266f6a5eef63..101cbe9c4a31f 100644 --- a/src/acp/types.ts +++ b/src/acp/types.ts @@ -1,6 +1,22 @@ import type { SessionId } from "@agentclientprotocol/sdk"; import { VERSION } from "../version.js"; +export const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const; + +export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number]; + +export function normalizeAcpProvenanceMode( + value: string | undefined, +): AcpProvenanceMode | undefined { + if (!value) { + return undefined; + } + const normalized = value.trim().toLowerCase(); + return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized) + ? (normalized as AcpProvenanceMode) + : undefined; +} + export type AcpSession = { sessionId: SessionId; sessionKey: string; @@ -20,6 +36,7 @@ export type AcpServerOptions = { requireExistingSession?: boolean; resetSession?: boolean; prefixCwd?: boolean; + provenanceMode?: AcpProvenanceMode; sessionCreateRateLimit?: { maxRequests?: number; windowMs?: number; diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index b7ec4858e519e..36e45bd9bf162 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -175,6 +175,7 @@ export function buildEmbeddedRunBaseParams(params: { config: params.run.config, skillsSnapshot: params.run.skillsSnapshot, ownerNumbers: params.run.ownerNumbers, + inputProvenance: params.run.inputProvenance, senderIsOwner: params.run.senderIsOwner, enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider), provider: params.provider, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 704688ddf6dca..dceac522eca23 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -521,6 +521,7 @@ export async function runPreparedReply( timeoutMs, blockReplyBreak: resolvedBlockStreamingBreak, ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined, + inputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance, extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined, ...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}), }, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 929f02e0726cd..507f77d499d42 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -2,6 +2,7 @@ import type { ExecToolDefaults } from "../../../agents/bash-tools.js"; import type { SkillSnapshot } from "../../../agents/skills.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { SessionEntry } from "../../../config/sessions.js"; +import type { InputProvenance } from "../../../sessions/input-provenance.js"; import type { OriginatingChannelType } from "../../templating.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js"; @@ -77,6 +78,7 @@ export type FollowupRun = { timeoutMs: number; blockReplyBreak: "text_end" | "message_end"; ownerNumbers?: string[]; + inputProvenance?: InputProvenance; extraSystemPrompt?: string; enforceFinalTag?: boolean; }; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index ae6a7917ff8da..cc4fc49e93f3e 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -3,6 +3,7 @@ import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.js"; +import type { InputProvenance } from "../sessions/input-provenance.js"; import type { StickerMetadata } from "../telegram/bot/types.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; @@ -117,6 +118,8 @@ export type MsgContext = { GroupSystemPrompt?: string; /** Untrusted metadata that must not be treated as system instructions. */ UntrustedContext?: string[]; + /** System-attached provenance for the current inbound message. */ + InputProvenance?: InputProvenance; /** Explicit owner allowlist overrides (trusted, configuration-derived). */ OwnerAllowFrom?: Array; SenderName?: string; diff --git a/src/cli/acp-cli.ts b/src/cli/acp-cli.ts index c4c7b09aeaf7d..a769e23459277 100644 --- a/src/cli/acp-cli.ts +++ b/src/cli/acp-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { runAcpClientInteractive } from "../acp/client.js"; import { readSecretFromFile } from "../acp/secret-file.js"; import { serveAcpGateway } from "../acp/server.js"; +import { normalizeAcpProvenanceMode } from "../acp/types.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -45,6 +46,7 @@ export function registerAcpCli(program: Command) { .option("--require-existing", "Fail if the session key/label does not exist", false) .option("--reset-session", "Reset the session key before first use", false) .option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false) + .option("--provenance ", "ACP provenance mode: off, meta, or meta+receipt") .option("-v, --verbose", "Verbose logging to stderr", false) .addHelpText( "after", @@ -72,6 +74,10 @@ export function registerAcpCli(program: Command) { if (opts.password) { warnSecretCliFlag("--password"); } + const provenanceMode = normalizeAcpProvenanceMode(opts.provenance as string | undefined); + if (opts.provenance && !provenanceMode) { + throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt."); + } await serveAcpGateway({ gatewayUrl: opts.url as string | undefined, gatewayToken, @@ -81,6 +87,7 @@ export function registerAcpCli(program: Command) { requireExistingSession: Boolean(opts.requireExisting), resetSession: Boolean(opts.resetSession), prefixCwd: !opts.noPrefixCwd, + provenanceMode, verbose: Boolean(opts.verbose), }); } catch (err) { diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 68b3fb0b83c86..75d560ba92b99 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -100,6 +100,7 @@ export const AgentParamsSchema = Type.Object( Type.Object( { kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }), + originSessionId: Type.Optional(Type.String()), sourceSessionKey: Type.Optional(Type.String()), sourceChannel: Type.Optional(Type.String()), sourceTool: Type.Optional(Type.String()), diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index ffa01945c011d..5545bd443f18f 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -1,4 +1,5 @@ import { Type } from "@sinclair/typebox"; +import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js"; import { ChatSendSessionKeyString, NonEmptyString } from "./primitives.js"; export const LogsTailParamsSchema = Type.Object( @@ -39,6 +40,19 @@ export const ChatSendParamsSchema = Type.Object( deliver: Type.Optional(Type.Boolean()), attachments: Type.Optional(Type.Array(Type.Unknown())), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), + systemInputProvenance: Type.Optional( + Type.Object( + { + kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }), + originSessionId: Type.Optional(Type.String()), + sourceSessionKey: Type.Optional(Type.String()), + sourceChannel: Type.Optional(Type.String()), + sourceTool: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), + systemProvenanceReceipt: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 37f5a0cfb6f8d..1415ef6d6f77a 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -158,6 +158,8 @@ async function runNonStreamingChatSend(params: { deliver?: boolean; client?: unknown; expectBroadcast?: boolean; + requestParams?: Record; + waitForCompletion?: boolean; }) { const sendParams: { sessionKey: string; @@ -173,7 +175,10 @@ async function runNonStreamingChatSend(params: { sendParams.deliver = params.deliver; } await chatHandlers["chat.send"]({ - params: sendParams, + params: { + ...sendParams, + ...params.requestParams, + }, respond: params.respond as unknown as Parameters< (typeof chatHandlers)["chat.send"] >[0]["respond"], @@ -185,6 +190,9 @@ async function runNonStreamingChatSend(params: { const shouldExpectBroadcast = params.expectBroadcast ?? true; if (!shouldExpectBroadcast) { + if (params.waitForCompletion === false) { + return undefined; + } await vi.waitFor(() => { expect(params.context.dedupe.has(`chat:${params.idempotencyKey}`)).toBe(true); }, FAST_WAIT_OPTS); @@ -885,4 +893,77 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }), ); }); + + it("rejects reserved system provenance fields for non-ACP clients", async () => { + createTranscriptFixture("openclaw-chat-send-system-provenance-reject-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-system-provenance-reject", + requestParams: { + systemInputProvenance: { kind: "external_user", sourceChannel: "acp" }, + systemProvenanceReceipt: "[Source Receipt]\nbridge=openclaw-acp\n[/Source Receipt]", + }, + expectBroadcast: false, + waitForCompletion: false, + }); + + const [ok, _payload, error] = respond.mock.calls.at(-1) ?? []; + expect(ok).toBe(false); + expect(error).toMatchObject({ + message: "system provenance fields are reserved for the ACP bridge", + }); + expect(mockState.lastDispatchCtx).toBeUndefined(); + }); + + it("injects ACP system provenance into the agent-visible body", async () => { + createTranscriptFixture("openclaw-chat-send-system-provenance-acp-"); + mockState.finalText = "ok"; + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-system-provenance-acp", + message: "bench update", + client: { + connect: { + client: { + id: "cli", + mode: "cli", + displayName: "ACP", + version: "acp", + }, + }, + }, + requestParams: { + systemInputProvenance: { + kind: "external_user", + originSessionId: "acp-session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }, + systemProvenanceReceipt: + "[Source Receipt]\nbridge=openclaw-acp\noriginSessionId=acp-session-1\n[/Source Receipt]", + }, + expectBroadcast: false, + }); + + expect(mockState.lastDispatchCtx?.InputProvenance).toEqual({ + kind: "external_user", + originSessionId: "acp-session-1", + sourceChannel: "acp", + sourceTool: "openclaw_acp", + }); + expect(mockState.lastDispatchCtx?.Body).toBe( + "[Source Receipt]\nbridge=openclaw-acp\noriginSessionId=acp-session-1\n[/Source Receipt]\n\nbench update", + ); + expect(mockState.lastDispatchCtx?.RawBody).toBe("bench update"); + expect(mockState.lastDispatchCtx?.CommandBody).toBe("bench update"); + }); }); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 7b4adb5cd78fb..7166908038214 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -11,6 +11,7 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.j import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; +import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { @@ -32,7 +33,12 @@ import { } from "../chat-abort.js"; import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js"; import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js"; -import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap } from "../protocol/client-info.js"; +import { + GATEWAY_CLIENT_CAPS, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + hasGatewayClientCap, +} from "../protocol/client-info.js"; import { ErrorCodes, errorShape, @@ -55,7 +61,11 @@ import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; import { appendInjectedAssistantMessageToTranscript } from "./chat-transcript-inject.js"; -import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; +import type { + GatewayRequestContext, + GatewayRequestHandlerOptions, + GatewayRequestHandlers, +} from "./types.js"; type TranscriptAppendResult = { ok: boolean; @@ -225,6 +235,33 @@ export function sanitizeChatSendMessageInput( return { ok: true, message: stripDisallowedChatControlChars(normalized) }; } +function normalizeOptionalChatSystemReceipt( + value: unknown, +): { ok: true; receipt?: string } | { ok: false; error: string } { + if (value == null) { + return { ok: true }; + } + if (typeof value !== "string") { + return { ok: false, error: "systemProvenanceReceipt must be a string" }; + } + const sanitized = sanitizeChatSendMessageInput(value); + if (!sanitized.ok) { + return sanitized; + } + const receipt = sanitized.message.trim(); + return { ok: true, receipt: receipt || undefined }; +} + +function isAcpBridgeClient(client: GatewayRequestHandlerOptions["client"]): boolean { + const info = client?.connect?.client; + return ( + info?.id === GATEWAY_CLIENT_NAMES.CLI && + info?.mode === GATEWAY_CLIENT_MODES.CLI && + info?.displayName === "ACP" && + info?.version === "acp" + ); +} + function truncateChatHistoryText(text: string): { text: string; truncated: boolean } { if (text.length <= CHAT_HISTORY_TEXT_MAX_CHARS) { return { text, truncated: false }; @@ -860,8 +897,21 @@ export const chatHandlers: GatewayRequestHandlers = { content?: unknown; }>; timeoutMs?: number; + systemInputProvenance?: InputProvenance; + systemProvenanceReceipt?: string; idempotencyKey: string; }; + if ((p.systemInputProvenance || p.systemProvenanceReceipt) && !isAcpBridgeClient(client)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "system provenance fields are reserved for the ACP bridge", + ), + ); + return; + } const sanitizedMessageResult = sanitizeChatSendMessageInput(p.message); if (!sanitizedMessageResult.ok) { respond( @@ -871,7 +921,14 @@ export const chatHandlers: GatewayRequestHandlers = { ); return; } + const systemReceiptResult = normalizeOptionalChatSystemReceipt(p.systemProvenanceReceipt); + if (!systemReceiptResult.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, systemReceiptResult.error)); + return; + } const inboundMessage = sanitizedMessageResult.message; + const systemInputProvenance = normalizeInputProvenance(p.systemInputProvenance); + const systemProvenanceReceipt = systemReceiptResult.receipt; const stopCommand = isChatStopCommandText(inboundMessage); const normalizedAttachments = normalizeRpcAttachmentsToChatAttachments(p.attachments); const rawMessage = inboundMessage.trim(); @@ -972,6 +1029,9 @@ export const chatHandlers: GatewayRequestHandlers = { p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"), ); const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage; + const messageForAgent = systemProvenanceReceipt + ? [systemProvenanceReceipt, parsedMessage].filter(Boolean).join("\n\n") + : parsedMessage; const clientInfo = client?.connect?.client; const { originatingChannel, @@ -990,14 +1050,15 @@ export const chatHandlers: GatewayRequestHandlers = { // Inject timestamp so agents know the current date/time. // Only BodyForAgent gets the timestamp — Body stays raw for UI display. // See: https://github.com/moltbot/moltbot/issues/3658 - const stampedMessage = injectTimestamp(parsedMessage, timestampOptsFromConfig(cfg)); + const stampedMessage = injectTimestamp(messageForAgent, timestampOptsFromConfig(cfg)); const ctx: MsgContext = { - Body: parsedMessage, + Body: messageForAgent, BodyForAgent: stampedMessage, BodyForCommands: commandBody, RawBody: parsedMessage, CommandBody: commandBody, + InputProvenance: systemInputProvenance, SessionKey: sessionKey, Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, diff --git a/src/sessions/input-provenance.ts b/src/sessions/input-provenance.ts index 4540e680612a6..7dc228eb320ca 100644 --- a/src/sessions/input-provenance.ts +++ b/src/sessions/input-provenance.ts @@ -10,6 +10,7 @@ export type InputProvenanceKind = (typeof INPUT_PROVENANCE_KIND_VALUES)[number]; export type InputProvenance = { kind: InputProvenanceKind; + originSessionId?: string; sourceSessionKey?: string; sourceChannel?: string; sourceTool?: string; @@ -39,6 +40,7 @@ export function normalizeInputProvenance(value: unknown): InputProvenance | unde } return { kind: record.kind, + originSessionId: normalizeOptionalString(record.originSessionId), sourceSessionKey: normalizeOptionalString(record.sourceSessionKey), sourceChannel: normalizeOptionalString(record.sourceChannel), sourceTool: normalizeOptionalString(record.sourceTool), From adec8b28bbb475ee882190818df590d6a5e49542 Mon Sep 17 00:00:00 2001 From: Kesku <62210496+kesku@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:24:54 -0700 Subject: [PATCH 204/311] alphabetize web search providers (#40259) Merged via squash. Prepared head SHA: be6350e5ae1c0610f813483dca0a5c98813a7f8e Co-authored-by: kesku <62210496+kesku@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + docs/tools/web.md | 8 +-- src/agents/tools/web-search.ts | 66 +++++++++---------- src/commands/configure.wizard.ts | 5 +- src/commands/onboard-search.ts | 14 ++-- src/config/config.web-search-provider.test.ts | 12 ++-- src/config/schema.help.ts | 8 +-- src/config/schema.labels.ts | 8 +-- src/config/types.tools.ts | 38 +++++------ 9 files changed, 82 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 437757f61a607..87586e9f767d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. - CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. - ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky. +- Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku. ### Breaking diff --git a/docs/tools/web.md b/docs/tools/web.md index 25cb5d7f4f069..1eeb4eba7dba9 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -43,9 +43,9 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut 1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config 2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config -3. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config -4. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config -5. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config +3. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config +4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config +5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). @@ -212,10 +212,10 @@ Search the web using your configured provider. - `tools.web.search.enabled` must not be `false` (default: enabled) - API key for your chosen provider: - **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey` - - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` - **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey` - **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` + - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` ### Config diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1501063a9cf2e..47c5a5abc9434 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -21,7 +21,7 @@ import { writeCache, } from "./web-shared.js"; -const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini", "kimi"] as const; +const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -492,27 +492,26 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { } function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { - if (provider === "perplexity") { + if (provider === "brave") { return { - error: "missing_perplexity_api_key", - message: - "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", + error: "missing_brave_api_key", + message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, docs: "https://docs.openclaw.ai/tools/web", }; } - if (provider === "grok") { + if (provider === "gemini") { return { - error: "missing_xai_api_key", + error: "missing_gemini_api_key", message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", + "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", docs: "https://docs.openclaw.ai/tools/web", }; } - if (provider === "gemini") { + if (provider === "grok") { return { - error: "missing_gemini_api_key", + error: "missing_xai_api_key", message: - "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", docs: "https://docs.openclaw.ai/tools/web", }; } @@ -525,8 +524,9 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { }; } return { - error: "missing_brave_api_key", - message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, + error: "missing_perplexity_api_key", + message: + "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", docs: "https://docs.openclaw.ai/tools/web", }; } @@ -536,32 +536,32 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE search && "provider" in search && typeof search.provider === "string" ? search.provider.trim().toLowerCase() : ""; - if (raw === "perplexity") { - return "perplexity"; - } - if (raw === "grok") { - return "grok"; + if (raw === "brave") { + return "brave"; } if (raw === "gemini") { return "gemini"; } + if (raw === "grok") { + return "grok"; + } if (raw === "kimi") { return "kimi"; } - if (raw === "brave") { - return "brave"; + if (raw === "perplexity") { + return "perplexity"; } - // Auto-detect provider from available API keys (priority order) + // Auto-detect provider from available API keys (alphabetical order) if (raw === "") { - // 1. Brave + // Brave if (resolveSearchApiKey(search)) { logVerbose( 'web_search: no provider configured, auto-detected "brave" from available API keys', ); return "brave"; } - // 2. Gemini + // Gemini const geminiConfig = resolveGeminiConfig(search); if (resolveGeminiApiKey(geminiConfig)) { logVerbose( @@ -569,7 +569,15 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "gemini"; } - // 3. Kimi + // Grok + const grokConfig = resolveGrokConfig(search); + if (resolveGrokApiKey(grokConfig)) { + logVerbose( + 'web_search: no provider configured, auto-detected "grok" from available API keys', + ); + return "grok"; + } + // Kimi const kimiConfig = resolveKimiConfig(search); if (resolveKimiApiKey(kimiConfig)) { logVerbose( @@ -577,7 +585,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "kimi"; } - // 4. Perplexity + // Perplexity const perplexityConfig = resolvePerplexityConfig(search); const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); if (perplexityKey) { @@ -586,14 +594,6 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ); return "perplexity"; } - // 5. Grok - const grokConfig = resolveGrokConfig(search); - if (resolveGrokApiKey(grokConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "grok" from available API keys', - ); - return "grok"; - } } return "brave"; diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 7a00fffbda138..80af67043ab05 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -188,7 +188,10 @@ async function promptWebToolsConfig( if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { return stored; } - return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "brave"; + return ( + SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? + SEARCH_PROVIDER_OPTIONS[0].value + ); })(); note( diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index f71a37b55da8b..df2f4643b60d7 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -10,7 +10,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; -export type SearchProvider = "perplexity" | "brave" | "gemini" | "grok" | "kimi"; +export type SearchProvider = "brave" | "gemini" | "grok" | "kimi" | "perplexity"; type SearchProviderEntry = { value: SearchProvider; @@ -73,14 +73,14 @@ function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown switch (provider) { case "brave": return search?.apiKey; - case "perplexity": - return search?.perplexity?.apiKey; case "gemini": return search?.gemini?.apiKey; case "grok": return search?.grok?.apiKey; case "kimi": return search?.kimi?.apiKey; + case "perplexity": + return search?.perplexity?.apiKey; } } @@ -132,9 +132,6 @@ export function applySearchKey( case "brave": search.apiKey = key; break; - case "perplexity": - search.perplexity = { ...search.perplexity, apiKey: key }; - break; case "gemini": search.gemini = { ...search.gemini, apiKey: key }; break; @@ -144,6 +141,9 @@ export function applySearchKey( case "kimi": search.kimi = { ...search.kimi, apiKey: key }; break; + case "perplexity": + search.perplexity = { ...search.perplexity, apiKey: key }; + break; } return { ...config, @@ -222,7 +222,7 @@ export async function setupSearch( if (detected) { return detected.value; } - return "brave"; + return SEARCH_PROVIDER_OPTIONS[0].value; })(); type PickerValue = SearchProvider | "__skip__"; diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 6aca3cf0d17a8..7ddb4ca3ab4e3 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -142,7 +142,7 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("kimi"); }); - it("follows priority order — brave wins when multiple keys available", () => { + it("follows alphabetical order — brave wins when multiple keys available", () => { process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret @@ -150,18 +150,18 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("brave"); }); - it("gemini wins over perplexity and grok when brave unavailable", () => { + it("gemini wins over grok, kimi, and perplexity when brave unavailable", () => { process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("gemini"); }); - it("brave wins over gemini and grok when perplexity unavailable", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret - process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + it("grok wins over kimi and perplexity when brave and gemini unavailable", () => { process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("brave"); + process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("grok"); }); it("explicit provider always wins regardless of keys", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index ec02d1d106fe5..08c579f89e3de 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -649,11 +649,13 @@ export const FIELD_HELP: Record = { "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", "tools.web.search.provider": - 'Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). Auto-detected from available API keys if omitted.', + 'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", - "tools.web.search.maxResults": "Default number of results to return (1-10).", + "tools.web.search.maxResults": "Number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.brave.mode": + 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.search.gemini.apiKey": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', @@ -670,8 +672,6 @@ export const FIELD_HELP: Record = { "Optional Perplexity/OpenRouter chat-completions base URL override. Setting this opts Perplexity into the legacy Sonar/OpenRouter compatibility path.", "tools.web.search.perplexity.model": 'Optional Sonar/OpenRouter model override (default: "perplexity/sonar-pro"). Setting this opts Perplexity into the legacy chat-completions compatibility path.', - "tools.web.search.brave.mode": - 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.maxCharsCap": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index ec9e8eb0c52b7..16bf21e8daf0a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -218,17 +218,17 @@ export const FIELD_LABELS: Record = { "tools.web.search.maxResults": "Web Search Max Results", "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", - "tools.web.search.perplexity.apiKey": "Perplexity API Key", // pragma: allowlist secret - "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", - "tools.web.search.perplexity.model": "Perplexity Model", + "tools.web.search.brave.mode": "Brave Search Mode", "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", "tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret "tools.web.search.grok.model": "Grok Search Model", - "tools.web.search.brave.mode": "Brave Search Mode", "tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret "tools.web.search.kimi.baseUrl": "Kimi Search Base URL", "tools.web.search.kimi.model": "Kimi Search Model", + "tools.web.search.perplexity.apiKey": "Perplexity API Key", // pragma: allowlist secret + "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", + "tools.web.search.perplexity.model": "Perplexity Model", "tools.web.fetch.enabled": "Enable Web Fetch Tool", "tools.web.fetch.maxChars": "Web Fetch Max Chars", "tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index e895e3bcf4f4f..89775758411a7 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -441,8 +441,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). */ - provider?: "brave" | "perplexity" | "grok" | "gemini" | "kimi"; + /** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */ + provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: SecretInput; /** Default search results count (1-10). */ @@ -451,13 +451,16 @@ export type ToolsConfig = { timeoutSeconds?: number; /** Cache TTL in minutes for search results. */ cacheTtlMinutes?: number; - /** Perplexity-specific configuration (used when provider="perplexity"). */ - perplexity?: { - /** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */ + /** Brave-specific configuration (used when provider="brave"). */ + brave?: { + /** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */ + mode?: "web" | "llm-context"; + }; + /** Gemini-specific configuration (used when provider="gemini"). */ + gemini?: { + /** Gemini API key (defaults to GEMINI_API_KEY env var). */ apiKey?: SecretInput; - /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ - baseUrl?: string; - /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ + /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ model?: string; }; /** Grok-specific configuration (used when provider="grok"). */ @@ -469,13 +472,6 @@ export type ToolsConfig = { /** Include inline citations in response text as markdown links (default: false). */ inlineCitations?: boolean; }; - /** Gemini-specific configuration (used when provider="gemini"). */ - gemini?: { - /** Gemini API key (defaults to GEMINI_API_KEY env var). */ - apiKey?: SecretInput; - /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ - model?: string; - }; /** Kimi-specific configuration (used when provider="kimi"). */ kimi?: { /** Moonshot/Kimi API key (defaults to KIMI_API_KEY or MOONSHOT_API_KEY env var). */ @@ -485,10 +481,14 @@ export type ToolsConfig = { /** Model to use (defaults to "moonshot-v1-128k"). */ model?: string; }; - /** Brave-specific configuration (used when provider="brave"). */ - brave?: { - /** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */ - mode?: "web" | "llm-context"; + /** Perplexity-specific configuration (used when provider="perplexity"). */ + perplexity?: { + /** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */ + apiKey?: SecretInput; + /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ + baseUrl?: string; + /** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */ + model?: string; }; }; fetch?: { From a438ff4397b3500fec0f263b81cdbebbaa55f79d Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 9 Mar 2026 11:32:45 +0800 Subject: [PATCH 205/311] fix(plugin-sdk): remove remaining bundled plugin src imports (openclaw#39638) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Kyle <3477429+kyledh@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../bluebubbles/src/monitor-normalize.ts | 2 +- .../mattermost/src/mattermost/monitor.ts | 2 +- extensions/nextcloud-talk/src/channel.ts | 2 +- extensions/twitch/src/token.ts | 7 ++++-- extensions/twitch/src/types.ts | 24 +++++++++---------- src/plugin-sdk/bluebubbles.ts | 1 + src/plugin-sdk/mattermost.ts | 1 + src/plugin-sdk/nextcloud-talk.ts | 1 + src/plugin-sdk/subpaths.test.ts | 15 ++++++++++++ src/plugin-sdk/twitch.ts | 23 +++++++++++++++++- 11 files changed, 60 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87586e9f767d7..9a9a1716e86f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -767,6 +767,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade. +- Plugin SDK/bundled subpath contracts: add regression coverage for newly routed bundled-plugin SDK exports so BlueBubbles, Mattermost, Nextcloud Talk, and Twitch subpath symbols stay pinned during future plugin-sdk cleanup. (#39638) - Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting. - Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting. - Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 22705e6b12c22..173ea9c24a6c3 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -1,4 +1,4 @@ -import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js"; +import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import type { BlueBubblesAttachment } from "./types.js"; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index d6f4bd9543c43..93d4ce1cfcb38 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -19,6 +19,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, isDangerousNameMatchingEnabled, + parseStrictPositiveInteger, registerPluginHttpRoute, resolveControlCommandGate, readStoreAllowFromForDmPolicy, @@ -30,7 +31,6 @@ import { listSkillCommandsForAgents, type HistoryEntry, } from "openclaw/plugin-sdk/mattermost"; -import { parseStrictPositiveInteger } from "../../../../src/infra/parse-finite-number.js"; import { getMattermostRuntime } from "../runtime.js"; import { resolveMattermostAccount } from "./accounts.js"; import { diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 711ac34cb52f2..6fdf36e9f8c38 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -15,11 +15,11 @@ import { deleteAccountFromConfigSection, normalizeAccountId, setAccountEnabledInConfigSection, + waitForAbortSignal, type ChannelPlugin, type OpenClawConfig, type ChannelSetupInput, } from "openclaw/plugin-sdk/nextcloud-talk"; -import { waitForAbortSignal } from "../../../src/infra/abort-signal.js"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index 4c3eae6a28a04..76f0c2007aaa7 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -9,8 +9,11 @@ * 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only) */ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type OpenClawConfig, +} from "openclaw/plugin-sdk/twitch"; export type TwitchTokenSource = "env" | "config" | "none"; diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 25aaf3bd80e23..8bb677bdc3e69 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -5,26 +5,24 @@ * from OpenClaw core. */ -import type { - ChannelGatewayContext, - ChannelOutboundAdapter, - ChannelOutboundContext, - ChannelResolveKind, - ChannelResolveResult, - ChannelStatusAdapter, -} from "../../../src/channels/plugins/types.adapters.js"; import type { ChannelAccountSnapshot, ChannelCapabilities, + ChannelGatewayContext, ChannelLogSink, ChannelMessageActionAdapter, ChannelMessageActionContext, ChannelMeta, -} from "../../../src/channels/plugins/types.core.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; + ChannelOutboundAdapter, + ChannelOutboundContext, + ChannelPlugin, + ChannelResolveKind, + ChannelResolveResult, + ChannelStatusAdapter, + OpenClawConfig, + OutboundDeliveryResult, + RuntimeEnv, +} from "openclaw/plugin-sdk/twitch"; // ============================================================================ // Twitch-Specific Types diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 736640b5a295b..19f74c30c2811 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -75,6 +75,7 @@ export { resolveServicePrefixedTarget, } from "../imessage/target-parsing-helpers.js"; export { stripMarkdown } from "../line/markdown-to-line.js"; +export { parseFiniteNumber } from "../infra/parse-finite-number.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index c680b6606c8d8..7b574dd3eeb93 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -77,6 +77,7 @@ export { requireOpenAllowFrom, } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; +export { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; export { rawDataToString } from "../infra/ws.js"; export { isLoopbackHost, isTrustedProxyAddress, resolveClientIp } from "../gateway/net.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index ff22f937cf7f3..3f534a0ab5d56 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -73,6 +73,7 @@ export { readRequestBodyWithLimit, requestBodyErrorToText, } from "../infra/http-body.js"; +export { waitForAbortSignal } from "../infra/abort-signal.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 7d9e76ec6bc78..aff933894211f 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -105,4 +105,19 @@ describe("plugin-sdk subpath exports", () => { expect(mod, `subpath ${id} should resolve`).toBeTruthy(); } }); + + it("keeps the newly added bundled plugin-sdk contracts available", async () => { + const bluebubbles = await import("openclaw/plugin-sdk/bluebubbles"); + expect(typeof bluebubbles.parseFiniteNumber).toBe("function"); + + const mattermost = await import("openclaw/plugin-sdk/mattermost"); + expect(typeof mattermost.parseStrictPositiveInteger).toBe("function"); + + const nextcloudTalk = await import("openclaw/plugin-sdk/nextcloud-talk"); + expect(typeof nextcloudTalk.waitForAbortSignal).toBe("function"); + + const twitch = await import("openclaw/plugin-sdk/twitch"); + expect(typeof twitch.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof twitch.normalizeAccountId).toBe("function"); + }); }); diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index bd315b02c9a9c..7ea8a9f5f4b0e 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -3,17 +3,38 @@ export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { + ChannelGatewayContext, + ChannelOutboundAdapter, + ChannelOutboundContext, + ChannelResolveKind, + ChannelResolveResult, + ChannelStatusAdapter, +} from "../channels/plugins/types.adapters.js"; +export type { + BaseProbeResult, + ChannelAccountSnapshot, + ChannelCapabilities, + ChannelLogSink, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMeta, + ChannelStatusIssue, +} from "../channels/plugins/types.js"; +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, } from "../channels/plugins/onboarding-types.js"; export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; -export type { BaseProbeResult, ChannelStatusIssue } from "../channels/plugins/types.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export type { OpenClawConfig } from "../config/config.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; +export type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-id.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; From f114a5c63877e42973e19c7cb89d665ccf4b48e9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 9 Mar 2026 09:14:38 +0530 Subject: [PATCH 206/311] test: fix android talk config contract fixture --- test-fixtures/talk-config-contract.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-fixtures/talk-config-contract.json b/test-fixtures/talk-config-contract.json index c94952acaf9a7..9b34d3cc60ead 100644 --- a/test-fixtures/talk-config-contract.json +++ b/test-fixtures/talk-config-contract.json @@ -82,7 +82,7 @@ "provider": "elevenlabs", "normalizedPayload": false, "voiceId": "voice-legacy", - "apiKey": "legacy-key" + "apiKey": "xxxxx" }, "talk": { "voiceId": "voice-legacy", From ef36cb8cbcbd8cf7b04a2a92e1782925f48ae1cf Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:47:04 -0500 Subject: [PATCH 207/311] chore(acpx): move runtime test fixtures to test-utils (openclaw#40548) Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini --- CHANGELOG.md | 1 + extensions/acpx/src/runtime.test.ts | 4 ++-- .../test-fixtures.ts => test-utils/runtime-fixtures.ts} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename extensions/acpx/src/{runtime-internals/test-fixtures.ts => test-utils/runtime-fixtures.ts} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9a1716e86f4..f1832db6255f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code. - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 53fc3c1f8a3fd..bb3b94cec9e58 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -2,13 +2,13 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js"; +import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js"; import { cleanupMockRuntimeFixtures, createMockRuntimeFixture, NOOP_LOGGER, readMockRuntimeLogEntries, -} from "./runtime-internals/test-fixtures.js"; -import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js"; +} from "./test-utils/runtime-fixtures.js"; let sharedFixture: Awaited> | null = null; let missingCommandRuntime: AcpxRuntime | null = null; diff --git a/extensions/acpx/src/runtime-internals/test-fixtures.ts b/extensions/acpx/src/test-utils/runtime-fixtures.ts similarity index 100% rename from extensions/acpx/src/runtime-internals/test-fixtures.ts rename to extensions/acpx/src/test-utils/runtime-fixtures.ts From e8775cda932fbe8c949cba925abdc8e419bb7829 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 03:41:24 +0000 Subject: [PATCH 208/311] fix(agents): re-expose configured tools under restrictive profiles --- src/agents/pi-tools.policy.test.ts | 57 ++++++++++++++++++++++++++++++ src/agents/pi-tools.policy.ts | 47 +++++++++++++++++++++--- src/plugins/config-state.test.ts | 37 ++++++++++++++++++- src/plugins/config-state.ts | 12 +++---- 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts index 4b7a16b4d92cf..0cdc572c44870 100644 --- a/src/agents/pi-tools.policy.test.ts +++ b/src/agents/pi-tools.policy.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { filterToolsByPolicy, isToolAllowedByPolicyName, + resolveEffectiveToolPolicy, resolveSubagentToolPolicy, } from "./pi-tools.policy.js"; import { createStubTool } from "./test-helpers/pi-tool-stubs.js"; @@ -176,3 +177,59 @@ describe("resolveSubagentToolPolicy depth awareness", () => { expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false); }); }); + +describe("resolveEffectiveToolPolicy", () => { + it("implicitly re-exposes exec and process when tools.exec is configured", () => { + const cfg = { + tools: { + profile: "messaging", + exec: { host: "sandbox" }, + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg }); + expect(result.profileAlsoAllow).toEqual(["exec", "process"]); + }); + + it("implicitly re-exposes read, write, and edit when tools.fs is configured", () => { + const cfg = { + tools: { + profile: "messaging", + fs: { workspaceOnly: false }, + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg }); + expect(result.profileAlsoAllow).toEqual(["read", "write", "edit"]); + }); + + it("merges explicit alsoAllow with implicit tool-section exposure", () => { + const cfg = { + tools: { + profile: "messaging", + alsoAllow: ["web_search"], + exec: { host: "sandbox" }, + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg }); + expect(result.profileAlsoAllow).toEqual(["web_search", "exec", "process"]); + }); + + it("uses agent tool sections when resolving implicit exposure", () => { + const cfg = { + tools: { + profile: "messaging", + }, + agents: { + list: [ + { + id: "coder", + tools: { + fs: { workspaceOnly: true }, + }, + }, + ], + }, + } as OpenClawConfig; + const result = resolveEffectiveToolPolicy({ config: cfg, agentId: "coder" }); + expect(result.profileAlsoAllow).toEqual(["read", "write", "edit"]); + }); +}); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index db9a367552ee6..61d037dd9f3f9 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -2,6 +2,7 @@ import { getChannelDock } from "../channels/dock.js"; import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; @@ -196,6 +197,37 @@ function resolveProviderToolPolicy(params: { return undefined; } +function resolveExplicitProfileAlsoAllow(tools?: OpenClawConfig["tools"]): string[] | undefined { + return Array.isArray(tools?.alsoAllow) ? tools.alsoAllow : undefined; +} + +function hasExplicitToolSection(section: unknown): boolean { + return section !== undefined && section !== null; +} + +function resolveImplicitProfileAlsoAllow(params: { + globalTools?: OpenClawConfig["tools"]; + agentTools?: AgentToolsConfig; +}): string[] | undefined { + const implicit = new Set(); + if ( + hasExplicitToolSection(params.agentTools?.exec) || + hasExplicitToolSection(params.globalTools?.exec) + ) { + implicit.add("exec"); + implicit.add("process"); + } + if ( + hasExplicitToolSection(params.agentTools?.fs) || + hasExplicitToolSection(params.globalTools?.fs) + ) { + implicit.add("read"); + implicit.add("write"); + implicit.add("edit"); + } + return implicit.size > 0 ? Array.from(implicit) : undefined; +} + export function resolveEffectiveToolPolicy(params: { config?: OpenClawConfig; sessionKey?: string; @@ -226,6 +258,15 @@ export function resolveEffectiveToolPolicy(params: { modelProvider: params.modelProvider, modelId: params.modelId, }); + const explicitProfileAlsoAllow = + resolveExplicitProfileAlsoAllow(agentTools) ?? resolveExplicitProfileAlsoAllow(globalTools); + const implicitProfileAlsoAllow = resolveImplicitProfileAlsoAllow({ globalTools, agentTools }); + const profileAlsoAllow = + explicitProfileAlsoAllow || implicitProfileAlsoAllow + ? Array.from( + new Set([...(explicitProfileAlsoAllow ?? []), ...(implicitProfileAlsoAllow ?? [])]), + ) + : undefined; return { agentId, globalPolicy: pickSandboxToolPolicy(globalTools), @@ -235,11 +276,7 @@ export function resolveEffectiveToolPolicy(params: { profile, providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile, // alsoAllow is applied at the profile stage (to avoid being filtered out early). - profileAlsoAllow: Array.isArray(agentTools?.alsoAllow) - ? agentTools?.alsoAllow - : Array.isArray(globalTools?.alsoAllow) - ? globalTools?.alsoAllow - : undefined, + profileAlsoAllow, providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow) ? agentProviderPolicy?.alsoAllow : Array.isArray(providerPolicy?.alsoAllow) diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 47101c771cddb..ebb5d3668681d 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { + normalizePluginsConfig, + resolveEffectiveEnableState, + resolveEnableState, +} from "./config-state.js"; describe("normalizePluginsConfig", () => { it("uses default memory slot when not specified", () => { @@ -111,3 +115,34 @@ describe("resolveEffectiveEnableState", () => { expect(state).toEqual({ enabled: false, reason: "disabled in config" }); }); }); + +describe("resolveEnableState", () => { + it("keeps the selected memory slot plugin enabled even when omitted from plugins.allow", () => { + const state = resolveEnableState( + "memory-core", + "bundled", + normalizePluginsConfig({ + allow: ["telegram"], + slots: { memory: "memory-core" }, + }), + ); + expect(state).toEqual({ enabled: true }); + }); + + it("keeps explicit disable authoritative for the selected memory slot plugin", () => { + const state = resolveEnableState( + "memory-core", + "bundled", + normalizePluginsConfig({ + allow: ["telegram"], + slots: { memory: "memory-core" }, + entries: { + "memory-core": { + enabled: false, + }, + }, + }), + ); + expect(state).toEqual({ enabled: false, reason: "disabled in config" }); + }); +}); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 2a70033bad27b..e671aae7e2e95 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -197,19 +197,19 @@ export function resolveEnableState( if (config.deny.includes(id)) { return { enabled: false, reason: "blocked by denylist" }; } - if (config.allow.length > 0 && !config.allow.includes(id)) { - return { enabled: false, reason: "not in allowlist" }; + const entry = config.entries[id]; + if (entry?.enabled === false) { + return { enabled: false, reason: "disabled in config" }; } if (config.slots.memory === id) { return { enabled: true }; } - const entry = config.entries[id]; + if (config.allow.length > 0 && !config.allow.includes(id)) { + return { enabled: false, reason: "not in allowlist" }; + } if (entry?.enabled === true) { return { enabled: true }; } - if (entry?.enabled === false) { - return { enabled: false, reason: "disabled in config" }; - } if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { return { enabled: true }; } From 99cbda83a2a27d38656e7d15e1a6adec626fd4b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 03:41:26 +0000 Subject: [PATCH 209/311] fix(media): accept reader read result type --- src/media/read-response-with-limit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/media/read-response-with-limit.ts b/src/media/read-response-with-limit.ts index c9ac52c8035a7..1c1a680e965df 100644 --- a/src/media/read-response-with-limit.ts +++ b/src/media/read-response-with-limit.ts @@ -1,7 +1,7 @@ async function readChunkWithIdleTimeout( reader: ReadableStreamDefaultReader, chunkTimeoutMs: number, -): Promise> { +): Promise>> { let timeoutId: ReturnType | undefined; let timedOut = false; From 8befd88119e2dd6a871e5478a3910d30cd5533f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 03:44:43 +0000 Subject: [PATCH 210/311] build(protocol): sync generated swift models --- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift | 8 ++++++++ .../Sources/OpenClawProtocol/GatewayModels.swift | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index ea44d030eb006..a6223d95bee3d 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -3257,6 +3257,8 @@ public struct ChatSendParams: Codable, Sendable { public let deliver: Bool? public let attachments: [AnyCodable]? public let timeoutms: Int? + public let systeminputprovenance: [String: AnyCodable]? + public let systemprovenancereceipt: String? public let idempotencykey: String public init( @@ -3266,6 +3268,8 @@ public struct ChatSendParams: Codable, Sendable { deliver: Bool?, attachments: [AnyCodable]?, timeoutms: Int?, + systeminputprovenance: [String: AnyCodable]?, + systemprovenancereceipt: String?, idempotencykey: String) { self.sessionkey = sessionkey @@ -3274,6 +3278,8 @@ public struct ChatSendParams: Codable, Sendable { self.deliver = deliver self.attachments = attachments self.timeoutms = timeoutms + self.systeminputprovenance = systeminputprovenance + self.systemprovenancereceipt = systemprovenancereceipt self.idempotencykey = idempotencykey } @@ -3284,6 +3290,8 @@ public struct ChatSendParams: Codable, Sendable { case deliver case attachments case timeoutms = "timeoutMs" + case systeminputprovenance = "systemInputProvenance" + case systemprovenancereceipt = "systemProvenanceReceipt" case idempotencykey = "idempotencyKey" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ea44d030eb006..a6223d95bee3d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3257,6 +3257,8 @@ public struct ChatSendParams: Codable, Sendable { public let deliver: Bool? public let attachments: [AnyCodable]? public let timeoutms: Int? + public let systeminputprovenance: [String: AnyCodable]? + public let systemprovenancereceipt: String? public let idempotencykey: String public init( @@ -3266,6 +3268,8 @@ public struct ChatSendParams: Codable, Sendable { deliver: Bool?, attachments: [AnyCodable]?, timeoutms: Int?, + systeminputprovenance: [String: AnyCodable]?, + systemprovenancereceipt: String?, idempotencykey: String) { self.sessionkey = sessionkey @@ -3274,6 +3278,8 @@ public struct ChatSendParams: Codable, Sendable { self.deliver = deliver self.attachments = attachments self.timeoutms = timeoutms + self.systeminputprovenance = systeminputprovenance + self.systemprovenancereceipt = systemprovenancereceipt self.idempotencykey = idempotencykey } @@ -3284,6 +3290,8 @@ public struct ChatSendParams: Codable, Sendable { case deliver case attachments case timeoutms = "timeoutMs" + case systeminputprovenance = "systemInputProvenance" + case systemprovenancereceipt = "systemProvenanceReceipt" case idempotencykey = "idempotencyKey" } } From 26e76f9a6149c78a62bcd1531ad58894e1677ea7 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 9 Mar 2026 09:31:05 +0530 Subject: [PATCH 211/311] fix: dedupe inbound Telegram DM replies per agent (#40519) Merged via squash. Prepared head SHA: 6e235e7d1f7a00ef43455a9240b62e24dbc4ef94 Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/auto-reply/inbound.test.ts | 28 ++++++++++++++-- .../reply/dispatch-from-config.test.ts | 32 +++++++++++++++++++ src/auto-reply/reply/inbound-dedupe.ts | 22 +++++++++++-- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1832db6255f3..1cbe70540ad87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark. - Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii. - Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung. +- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. ## 2026.3.7 diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index f602c7dca602e..4d624ecabd1d5 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -236,7 +236,7 @@ describe("inbound dedupe", () => { ).toBe(false); }); - it("does not dedupe across session keys", () => { + it("does not dedupe across agent ids", () => { resetInboundDedupe(); const base: MsgContext = { Provider: "whatsapp", @@ -248,12 +248,36 @@ describe("inbound dedupe", () => { shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }), ).toBe(false); expect( - shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }), + shouldSkipDuplicateInbound( + { ...base, SessionKey: "agent:bravo:whatsapp:direct:+1555" }, + { + now: 200, + }, + ), ).toBe(false); expect( shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }), ).toBe(true); }); + + it("dedupes when the same agent sees the same inbound message under different session keys", () => { + resetInboundDedupe(); + const base: MsgContext = { + Provider: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:7463849194", + MessageSid: "msg-1", + }; + expect( + shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:main:main" }, { now: 100 }), + ).toBe(false); + expect( + shouldSkipDuplicateInbound( + { ...base, SessionKey: "agent:main:telegram:direct:7463849194" }, + { now: 200 }, + ), + ).toBe(true); + }); }); describe("createInboundDebouncer", () => { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index cb71c9b09ba04..982557ecb6803 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -1539,6 +1539,38 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("deduplicates same-agent inbound replies across main and direct session keys", async () => { + setNoAbort(); + const cfg = emptyConfig; + const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); + const baseCtx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:7463849194", + MessageSid: "msg-1", + SessionKey: "agent:main:main", + }); + + await dispatchReplyFromConfig({ + ctx: baseCtx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + await dispatchReplyFromConfig({ + ctx: { + ...baseCtx, + SessionKey: "agent:main:telegram:direct:7463849194", + }, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + }); + it("emits message_received hook with originating channel metadata", async () => { setNoAbort(); hookMocks.runner.hasHooks.mockReturnValue(true); diff --git a/src/auto-reply/reply/inbound-dedupe.ts b/src/auto-reply/reply/inbound-dedupe.ts index 191e4c4f47843..0e4740261b9bd 100644 --- a/src/auto-reply/reply/inbound-dedupe.ts +++ b/src/auto-reply/reply/inbound-dedupe.ts @@ -1,5 +1,6 @@ import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { createDedupeCache, type DedupeCache } from "../../infra/dedupe.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import type { MsgContext } from "../templating.js"; const DEFAULT_INBOUND_DEDUPE_TTL_MS = 20 * 60_000; @@ -15,6 +16,23 @@ const normalizeProvider = (value?: string | null) => value?.trim().toLowerCase() const resolveInboundPeerId = (ctx: MsgContext) => ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? ctx.SessionKey; +function resolveInboundDedupeSessionScope(ctx: MsgContext): string { + const sessionKey = + (ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey : undefined)?.trim() || + ctx.SessionKey?.trim() || + ""; + if (!sessionKey) { + return ""; + } + const parsed = parseAgentSessionKey(sessionKey); + if (!parsed) { + return sessionKey; + } + // The same physical inbound message should never run twice for the same + // agent, even if a routing bug presents it under both main and direct keys. + return `agent:${parsed.agentId}`; +} + export function buildInboundDedupeKey(ctx: MsgContext): string | null { const provider = normalizeProvider(ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface); const messageId = ctx.MessageSid?.trim(); @@ -25,13 +43,13 @@ export function buildInboundDedupeKey(ctx: MsgContext): string | null { if (!peerId) { return null; } - const sessionKey = ctx.SessionKey?.trim() ?? ""; + const sessionScope = resolveInboundDedupeSessionScope(ctx); const accountId = ctx.AccountId?.trim() ?? ""; const threadId = ctx.MessageThreadId !== undefined && ctx.MessageThreadId !== null ? String(ctx.MessageThreadId) : ""; - return [provider, accountId, sessionKey, peerId, threadId, messageId].filter(Boolean).join("|"); + return [provider, accountId, sessionScope, peerId, threadId, messageId].filter(Boolean).join("|"); } export function shouldSkipDuplicateInbound( From d4a960fcca2abea4612c50185768d622294b9b7a Mon Sep 17 00:00:00 2001 From: Bronko Date: Mon, 9 Mar 2026 05:26:48 +0100 Subject: [PATCH 212/311] fix(matrix): restore robust DM routing without the memberCount heuristic (#19736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(matrix): remove memberCount heuristic from DM detection The memberCount === 2 check in isDirectMessage() misclassifies 2-person group rooms (admin channels, monitoring rooms) as DMs, routing them to the main session instead of their room-specific session. Matrix already distinguishes DMs from groups at the protocol level via m.direct account data and is_direct member state flags. Both are already checked by client.dms.isDm() and hasDirectFlag(). The memberCount heuristic only adds false positives for 2-person groups. Move resolveMemberCount() below the protocol-level checks so it is only reached for rooms not matched by m.direct or is_direct. This narrows its role to diagnostic logging for confirmed group rooms. Refs: #19739 * fix(matrix): add conservative fallback for broken DM flags Some homeservers (notably Continuwuity) have broken m.direct account data or never set is_direct on invite events. With the memberCount heuristic removed, these DMs are no longer detected. Add a conservative fallback that requires two signals before classifying as DM: memberCount === 2 AND no explicit m.room.name. Group rooms almost always have explicit names; DMs almost never do. Error handling distinguishes M_NOT_FOUND (missing state event, expected for unnamed rooms) from network/auth errors. Non-404 errors fall through to group classification rather than guessing. This is independently revertable — removing this commit restores pure protocol-based detection without any heuristic fallback. * fix(matrix): add parentPeer for DM room binding support Add parentPeer to DM routes so conversations are bindable by room ID while preserving DM trust semantics (secure 1:1, no group restrictions). Suggested by @KirillShchetinin. * fix(matrix): override DM detection for explicitly configured rooms Builds on @robertcorreiro's config-driven approach from #9106. Move resolveMatrixRoomConfig() before the DM check. If a room matches a non-wildcard config entry (matchSource === "direct") and was classified as DM, override the classification to group. This gives users a deterministic escape hatch for misclassified rooms. Wildcards are excluded from the override to avoid breaking DM routing when a "*" catch-all exists. roomConfig is gated behind isRoom so DMs never inherit group settings (skills, systemPrompt, autoReply). This commit is independently droppable if the scope is too broad. * test(matrix): add DM detection and config override tests - 15 unit tests for direct.ts: all detection paths, priority order, M_NOT_FOUND vs network error handling, edge cases (whitespace names, API failures) - 8 unit tests for rooms.ts: matchSource classification, wildcard safety for DM override, direct match priority over wildcard * Changelog: note matrix DM routing follow-up * fix(matrix): preserve DM fallback and room bindings --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../matrix/src/matrix/monitor/direct.test.ts | 413 ++++++++++++++++-- .../matrix/src/matrix/monitor/direct.ts | 61 ++- .../monitor/handler.body-for-agent.test.ts | 56 ++- .../matrix/src/matrix/monitor/handler.ts | 100 ++++- .../matrix/src/matrix/monitor/rooms.test.ts | 85 ++++ 6 files changed, 651 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbe70540ad87..79a5dec04567b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii. - Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung. - Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. +- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. ## 2026.3.7 diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 2f6471f4be32a..298b399683738 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -1,65 +1,400 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { describe, expect, it, vi } from "vitest"; import { createDirectRoomTracker } from "./direct.js"; -function createMockClient(params: { - isDm?: boolean; - senderDirect?: boolean; - selfDirect?: boolean; - members?: string[]; +// --------------------------------------------------------------------------- +// Helpers -- minimal MatrixClient stub +// --------------------------------------------------------------------------- + +type StateEvent = Record; +type DmMap = Record; + +function createMockClient(opts: { + dmRooms?: DmMap; + membersByRoom?: Record; + stateEvents?: Record; + selfUserId?: string; }) { - const members = params.members ?? ["@alice:example.org", "@bot:example.org"]; + const { + dmRooms = {}, + membersByRoom = {}, + stateEvents = {}, + selfUserId = "@bot:example.org", + } = opts; + return { dms: { + isDm: (roomId: string) => dmRooms[roomId] ?? false, update: vi.fn().mockResolvedValue(undefined), - isDm: vi.fn().mockReturnValue(params.isDm === true), }, - getUserId: vi.fn().mockResolvedValue("@bot:example.org"), - getJoinedRoomMembers: vi.fn().mockResolvedValue(members), + getUserId: vi.fn().mockResolvedValue(selfUserId), + getJoinedRoomMembers: vi.fn().mockImplementation(async (roomId: string) => { + return membersByRoom[roomId] ?? []; + }), getRoomStateEvent: vi .fn() - .mockImplementation(async (_roomId: string, _event: string, stateKey: string) => { - if (stateKey === "@alice:example.org") { - return { is_direct: params.senderDirect === true }; + .mockImplementation(async (roomId: string, eventType: string, stateKey: string) => { + const key = `${roomId}|${eventType}|${stateKey}`; + const ev = stateEvents[key]; + if (ev === undefined) { + // Simulate real homeserver M_NOT_FOUND response (matches MatrixError shape) + const err = new Error(`State event not found: ${key}`) as Error & { + errcode?: string; + statusCode?: number; + }; + err.errcode = "M_NOT_FOUND"; + err.statusCode = 404; + throw err; } - if (stateKey === "@bot:example.org") { - return { is_direct: params.selfDirect === true }; - } - return {}; + return ev; }), - } as unknown as MatrixClient; + }; } +// --------------------------------------------------------------------------- +// Tests -- isDirectMessage +// --------------------------------------------------------------------------- + describe("createDirectRoomTracker", () => { - it("treats m.direct rooms as DMs", async () => { - const tracker = createDirectRoomTracker(createMockClient({ isDm: true })); - await expect( - tracker.isDirectMessage({ - roomId: "!room:example.org", + describe("m.direct detection (SDK DM cache)", () => { + it("returns true when SDK DM cache marks room as DM", async () => { + const client = createMockClient({ + dmRooms: { "!dm:example.org": true }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!dm:example.org", senderId: "@alice:example.org", - }), - ).resolves.toBe(true); + }); + + expect(result).toBe(true); + }); + + it("returns false for rooms not in SDK DM cache (with >2 members)", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"], + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!group:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(false); + }); }); - it("does not classify 2-member rooms as DMs without direct flags", async () => { - const client = createMockClient({ isDm: false }); - const tracker = createDirectRoomTracker(client); - await expect( - tracker.isDirectMessage({ + describe("is_direct state flag detection", () => { + it("returns true when sender's membership has is_direct=true", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] }, + stateEvents: { + "!room:example.org|m.room.member|@alice:example.org": { is_direct: true }, + "!room:example.org|m.room.member|@bot:example.org": { is_direct: false }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ roomId: "!room:example.org", senderId: "@alice:example.org", - }), - ).resolves.toBe(false); - expect(client.getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + + expect(result).toBe(true); + }); + + it("returns true when bot's own membership has is_direct=true", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] }, + stateEvents: { + "!room:example.org|m.room.member|@alice:example.org": { is_direct: false }, + "!room:example.org|m.room.member|@bot:example.org": { is_direct: true }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + selfUserId: "@bot:example.org", + }); + + expect(result).toBe(true); + }); }); - it("uses is_direct member flags when present", async () => { - const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true })); - await expect( - tracker.isDirectMessage({ + describe("conservative fallback (memberCount + room name)", () => { + it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + stateEvents: { + // is_direct not set on either member (e.g. Continuwuity bug) + "!broken-dm:example.org|m.room.member|@alice:example.org": {}, + "!broken-dm:example.org|m.room.member|@bot:example.org": {}, + // No m.room.name -> getRoomStateEvent will throw (event not found) + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!broken-dm:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(true); + }); + + it("returns true for 2-member room with empty room name", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + stateEvents: { + "!broken-dm:example.org|m.room.member|@alice:example.org": {}, + "!broken-dm:example.org|m.room.member|@bot:example.org": {}, + "!broken-dm:example.org|m.room.name|": { name: "" }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!broken-dm:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(true); + }); + + it("returns false for 2-member room WITH a room name (named group)", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!named-group:example.org": ["@alice:example.org", "@bob:example.org"], + }, + stateEvents: { + "!named-group:example.org|m.room.member|@alice:example.org": {}, + "!named-group:example.org|m.room.member|@bob:example.org": {}, + "!named-group:example.org|m.room.name|": { name: "Project Alpha" }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!named-group:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(false); + }); + + it("returns false for 3+ member room without any DM signals", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"], + }, + stateEvents: { + "!group:example.org|m.room.member|@alice:example.org": {}, + "!group:example.org|m.room.member|@bob:example.org": {}, + "!group:example.org|m.room.member|@carol:example.org": {}, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!group:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(false); + }); + + it("returns false for 1-member room (self-chat)", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!solo:example.org": ["@bot:example.org"], + }, + stateEvents: { + "!solo:example.org|m.room.member|@bot:example.org": {}, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!solo:example.org", + senderId: "@bot:example.org", + }); + + expect(result).toBe(false); + }); + }); + + describe("detection priority", () => { + it("m.direct takes priority -- skips state and fallback checks", async () => { + const client = createMockClient({ + dmRooms: { "!dm:example.org": true }, + membersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"], + }, + stateEvents: { + "!dm:example.org|m.room.name|": { name: "Named Room" }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!dm:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(true); + // Should not have checked member state or room name + expect(client.getRoomStateEvent).not.toHaveBeenCalled(); + expect(client.getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + + it("is_direct takes priority over fallback -- skips member count", async () => { + const client = createMockClient({ + dmRooms: {}, + stateEvents: { + "!room:example.org|m.room.member|@alice:example.org": { is_direct: true }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ roomId: "!room:example.org", senderId: "@alice:example.org", - }), - ).resolves.toBe(true); + }); + + expect(result).toBe(true); + // Should not have checked member count + expect(client.getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + }); + + describe("edge cases", () => { + it("handles member count API failure gracefully", async () => { + const client = createMockClient({ + dmRooms: {}, + stateEvents: { + "!failing:example.org|m.room.member|@alice:example.org": {}, + "!failing:example.org|m.room.member|@bot:example.org": {}, + }, + }); + client.getJoinedRoomMembers.mockRejectedValue(new Error("API unavailable")); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!failing:example.org", + senderId: "@alice:example.org", + }); + + // Cannot determine member count -> conservative: classify as group + expect(result).toBe(false); + }); + + it("treats M_NOT_FOUND for room name as no name (DM)", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!no-name:example.org": ["@alice:example.org", "@bot:example.org"], + }, + stateEvents: { + "!no-name:example.org|m.room.member|@alice:example.org": {}, + "!no-name:example.org|m.room.member|@bot:example.org": {}, + // m.room.name not in stateEvents -> mock throws generic Error + }, + }); + // Override to throw M_NOT_FOUND like a real homeserver + const originalImpl = client.getRoomStateEvent.getMockImplementation()!; + client.getRoomStateEvent.mockImplementation( + async (roomId: string, eventType: string, stateKey: string) => { + if (eventType === "m.room.name") { + const err = new Error("not found") as Error & { + errcode?: string; + statusCode?: number; + }; + err.errcode = "M_NOT_FOUND"; + err.statusCode = 404; + throw err; + } + return originalImpl(roomId, eventType, stateKey); + }, + ); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!no-name:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(true); + }); + + it("treats non-404 room name errors as unknown (falls through to group)", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!error-room:example.org": ["@alice:example.org", "@bot:example.org"], + }, + stateEvents: { + "!error-room:example.org|m.room.member|@alice:example.org": {}, + "!error-room:example.org|m.room.member|@bot:example.org": {}, + }, + }); + // Simulate a network/auth error (not M_NOT_FOUND) + const originalImpl = client.getRoomStateEvent.getMockImplementation()!; + client.getRoomStateEvent.mockImplementation( + async (roomId: string, eventType: string, stateKey: string) => { + if (eventType === "m.room.name") { + throw new Error("Connection refused"); + } + return originalImpl(roomId, eventType, stateKey); + }, + ); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!error-room:example.org", + senderId: "@alice:example.org", + }); + + // Network error -> don't assume DM, classify as group + expect(result).toBe(false); + }); + + it("whitespace-only room name is treated as no name", async () => { + const client = createMockClient({ + dmRooms: {}, + membersByRoom: { + "!ws-name:example.org": ["@alice:example.org", "@bot:example.org"], + }, + stateEvents: { + "!ws-name:example.org|m.room.member|@alice:example.org": {}, + "!ws-name:example.org|m.room.member|@bot:example.org": {}, + "!ws-name:example.org|m.room.name|": { name: " " }, + }, + }); + const tracker = createDirectRoomTracker(client as never); + + const result = await tracker.isDirectMessage({ + roomId: "!ws-name:example.org", + senderId: "@alice:example.org", + }); + + expect(result).toBe(true); + }); }); }); diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index d938c57b4e5f7..43b935b35fab4 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -13,14 +13,22 @@ type DirectRoomTrackerOptions = { const DM_CACHE_TTL_MS = 30_000; +/** + * Check if an error is a Matrix M_NOT_FOUND response (missing state event). + * The bot-sdk throws MatrixError with errcode/statusCode on the error object. + */ +function isMatrixNotFoundError(err: unknown): boolean { + if (typeof err !== "object" || err === null) return false; + const e = err as { errcode?: string; statusCode?: number }; + return e.errcode === "M_NOT_FOUND" || e.statusCode === 404; +} + export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) { const log = opts.log ?? (() => {}); const includeMemberCountInLogs = opts.includeMemberCountInLogs === true; let lastDmUpdateMs = 0; let cachedSelfUserId: string | null = null; - const memberCountCache = includeMemberCountInLogs - ? new Map() - : undefined; + const memberCountCache = new Map(); const ensureSelfUserId = async (): Promise => { if (cachedSelfUserId) { @@ -48,9 +56,6 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr }; const resolveMemberCount = async (roomId: string): Promise => { - if (!memberCountCache) { - return null; - } const cached = memberCountCache.get(roomId); const now = Date.now(); if (cached && now - cached.ts < DM_CACHE_TTL_MS) { @@ -91,7 +96,6 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr return true; } - // Check m.room.member state for is_direct flag const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); const directViaState = (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? "")); @@ -100,16 +104,47 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr return true; } - // Member count alone is NOT a reliable DM indicator. - // Explicitly configured group rooms with 2 members (e.g. bot + one user) - // were being misclassified as DMs, causing messages to be routed through - // DM policy instead of group policy and silently dropped. - // See: https://github.com/openclaw/openclaw/issues/20145 + // Conservative fallback: 2-member rooms without an explicit room name are likely + // DMs with broken m.direct / is_direct flags. This has been observed on Continuwuity + // where m.direct pointed to the wrong room and is_direct was never set on the invite. + // Unlike the removed heuristic, this requires two signals (member count + no name) + // to avoid false positives on named 2-person group rooms. + // + // Performance: member count is cached (resolveMemberCount). The room name state + // check is not cached but only runs for the subset of 2-member rooms that reach + // this fallback path (no m.direct, no is_direct). In typical deployments this is + // a small minority of rooms. + // + // Note: there is a narrow race where a room name is being set concurrently with + // this check. The consequence is a one-time misclassification that self-corrects + // on the next message (once the state event is synced). This is acceptable given + // the alternative of an additional API call on every message. + const memberCount = await resolveMemberCount(roomId); + if (memberCount === 2) { + try { + const nameState = await client.getRoomStateEvent(roomId, "m.room.name", ""); + if (!nameState?.name?.trim()) { + log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`); + return true; + } + } catch (err: unknown) { + // Missing state events (M_NOT_FOUND) are expected for unnamed rooms and + // strongly indicate a DM. Any other error (network, auth) is ambiguous, + // so we fall through to classify as group rather than guess. + if (isMatrixNotFoundError(err)) { + log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`); + return true; + } + log( + `matrix: dm fallback skipped (room name check failed: ${String(err)}) room=${roomId}`, + ); + } + } + if (!includeMemberCountInLogs) { log(`matrix: dm check room=${roomId} result=group`); return false; } - const memberCount = await resolveMemberCount(roomId); log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`); return false; }, diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index 83cab3b478098..15665563039c7 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -1,7 +1,11 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; -import { createMatrixRoomMessageHandler } from "./handler.js"; +import { + createMatrixRoomMessageHandler, + resolveMatrixBaseRouteSession, + shouldOverrideMatrixDmToGroup, +} from "./handler.js"; import { EventType, type MatrixRawEvent } from "./types.js"; describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { @@ -18,8 +22,15 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { channel: { pairing: { readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn().mockResolvedValue(undefined), }, routing: { + buildAgentSessionKey: vi + .fn() + .mockImplementation( + (params: { agentId: string; channel: string; peer?: { kind: string; id: string } }) => + `agent:${params.agentId}:${params.channel}:${params.peer?.kind ?? "direct"}:${params.peer?.id ?? "unknown"}`, + ), resolveAgentRoute: vi.fn().mockReturnValue({ agentId: "main", accountId: undefined, @@ -139,4 +150,47 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { }), ); }); + + it("uses room-scoped session keys for DM rooms matched via parentPeer binding", () => { + const buildAgentSessionKey = vi + .fn() + .mockReturnValue("agent:main:matrix:channel:!dmroom:example.org"); + + const resolved = resolveMatrixBaseRouteSession({ + buildAgentSessionKey, + baseRoute: { + agentId: "main", + sessionKey: "agent:main:main", + mainSessionKey: "agent:main:main", + matchedBy: "binding.peer.parent", + }, + isDirectMessage: true, + roomId: "!dmroom:example.org", + accountId: undefined, + }); + + expect(buildAgentSessionKey).toHaveBeenCalledWith({ + agentId: "main", + channel: "matrix", + accountId: undefined, + peer: { kind: "channel", id: "!dmroom:example.org" }, + }); + expect(resolved).toEqual({ + sessionKey: "agent:main:matrix:channel:!dmroom:example.org", + lastRoutePolicy: "session", + }); + }); + + it("does not override DMs to groups for explicit allow:false room config", () => { + expect( + shouldOverrideMatrixDmToGroup({ + isDirectMessage: true, + roomConfigInfo: { + config: { allow: false }, + allowed: false, + matchSource: "direct", + }, + }), + ).toBe(false); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 295d61f2de0c5..0adc9fa28867a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -77,6 +77,56 @@ export type MatrixMonitorHandlerParams = { accountId?: string | null; }; +export function resolveMatrixBaseRouteSession(params: { + buildAgentSessionKey: (params: { + agentId: string; + channel: string; + accountId?: string | null; + peer?: { kind: "direct" | "channel"; id: string } | null; + }) => string; + baseRoute: { + agentId: string; + sessionKey: string; + mainSessionKey: string; + matchedBy?: string; + }; + isDirectMessage: boolean; + roomId: string; + accountId?: string | null; +}): { sessionKey: string; lastRoutePolicy: "main" | "session" } { + const sessionKey = + params.isDirectMessage && params.baseRoute.matchedBy === "binding.peer.parent" + ? params.buildAgentSessionKey({ + agentId: params.baseRoute.agentId, + channel: "matrix", + accountId: params.accountId, + peer: { kind: "channel", id: params.roomId }, + }) + : params.baseRoute.sessionKey; + return { + sessionKey, + lastRoutePolicy: sessionKey === params.baseRoute.mainSessionKey ? "main" : "session", + }; +} + +export function shouldOverrideMatrixDmToGroup(params: { + isDirectMessage: boolean; + roomConfigInfo?: + | { + config?: MatrixRoomConfig; + allowed: boolean; + matchSource?: string; + } + | undefined; +}): boolean { + return ( + params.isDirectMessage === true && + params.roomConfigInfo?.config !== undefined && + params.roomConfigInfo.allowed === true && + params.roomConfigInfo.matchSource === "direct" + ); +} + export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { const { client, @@ -188,22 +238,37 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } } - const isDirectMessage = await directTracker.isDirectMessage({ + let isDirectMessage = await directTracker.isDirectMessage({ roomId, senderId, selfUserId, }); + + // Resolve room config early so explicitly configured rooms can override DM classification. + // This ensures rooms in the groups config are always treated as groups regardless of + // member count or protocol-level DM flags. Only explicit matches (not wildcards) trigger + // the override to avoid breaking DM routing when a wildcard entry exists. (See #9106) + const roomConfigInfo = resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases: roomAliases, + name: roomName, + }); + if (shouldOverrideMatrixDmToGroup({ isDirectMessage, roomConfigInfo })) { + logVerboseMessage( + `matrix: overriding DM to group for configured room=${roomId} (${roomConfigInfo.matchKey})`, + ); + isDirectMessage = false; + } + const isRoom = !isDirectMessage; - const roomConfigInfo = isRoom - ? resolveMatrixRoomConfig({ - rooms: roomsConfig, - roomId, - aliases: roomAliases, - name: roomName, - }) - : undefined; - const roomConfig = roomConfigInfo?.config; + if (isRoom && groupPolicy === "disabled") { + return; + } + // Only expose room config for confirmed group rooms. DMs should never inherit + // group settings (skills, systemPrompt, autoReply) even when a wildcard entry exists. + const roomConfig = isRoom ? roomConfigInfo?.config : undefined; const roomMatchMeta = roomConfigInfo ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ roomConfigInfo.matchSource ?? "none" @@ -435,13 +500,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam kind: isDirectMessage ? "direct" : "channel", id: isDirectMessage ? senderId : roomId, }, + // For DMs, pass roomId as parentPeer so the conversation is bindable by room ID + // while preserving DM trust semantics (secure 1:1, no group restrictions). + parentPeer: isDirectMessage ? { kind: "channel", id: roomId } : undefined, + }); + const baseRouteSession = resolveMatrixBaseRouteSession({ + buildAgentSessionKey: core.channel.routing.buildAgentSessionKey, + baseRoute, + isDirectMessage, + roomId, + accountId, }); const route = { ...baseRoute, + lastRoutePolicy: baseRouteSession.lastRoutePolicy, sessionKey: threadRootId - ? `${baseRoute.sessionKey}:thread:${threadRootId}` - : baseRoute.sessionKey, + ? `${baseRouteSession.sessionKey}:thread:${threadRootId}` + : baseRouteSession.sessionKey, }; let threadStarterBody: string | undefined; diff --git a/extensions/matrix/src/matrix/monitor/rooms.test.ts b/extensions/matrix/src/matrix/monitor/rooms.test.ts index 21fe5a90474e4..9c94dc49ce0b3 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.test.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.test.ts @@ -36,4 +36,89 @@ describe("resolveMatrixRoomConfig", () => { expect(byName.allowed).toBe(false); expect(byName.config).toBeUndefined(); }); + + describe("matchSource classification", () => { + it('returns matchSource="direct" for exact room ID match', () => { + const result = resolveMatrixRoomConfig({ + rooms: { "!room:example.org": { allow: true } }, + roomId: "!room:example.org", + aliases: [], + }); + expect(result.matchSource).toBe("direct"); + expect(result.config).toBeDefined(); + }); + + it('returns matchSource="direct" for alias match', () => { + const result = resolveMatrixRoomConfig({ + rooms: { "#alias:example.org": { allow: true } }, + roomId: "!room:example.org", + aliases: ["#alias:example.org"], + }); + expect(result.matchSource).toBe("direct"); + expect(result.config).toBeDefined(); + }); + + it('returns matchSource="wildcard" for wildcard match', () => { + const result = resolveMatrixRoomConfig({ + rooms: { "*": { allow: true } }, + roomId: "!any:example.org", + aliases: [], + }); + expect(result.matchSource).toBe("wildcard"); + expect(result.config).toBeDefined(); + }); + + it("returns undefined matchSource when no match", () => { + const result = resolveMatrixRoomConfig({ + rooms: { "!other:example.org": { allow: true } }, + roomId: "!room:example.org", + aliases: [], + }); + expect(result.matchSource).toBeUndefined(); + expect(result.config).toBeUndefined(); + }); + + it("direct match takes priority over wildcard", () => { + const result = resolveMatrixRoomConfig({ + rooms: { + "!room:example.org": { allow: true, systemPrompt: "room-specific" }, + "*": { allow: true, systemPrompt: "generic" }, + }, + roomId: "!room:example.org", + aliases: [], + }); + expect(result.matchSource).toBe("direct"); + expect(result.config?.systemPrompt).toBe("room-specific"); + }); + }); + + describe("DM override safety (matchSource distinction)", () => { + // These tests verify the matchSource property that handler.ts uses + // to decide whether a configured room should override DM classification. + // Only "direct" matches should trigger the override -- never "wildcard". + + it("wildcard config should NOT be usable to override DM classification", () => { + const result = resolveMatrixRoomConfig({ + rooms: { "*": { allow: true, skills: ["general"] } }, + roomId: "!dm-room:example.org", + aliases: [], + }); + // handler.ts checks: matchSource === "direct" -> this is "wildcard", so no override + expect(result.matchSource).not.toBe("direct"); + expect(result.matchSource).toBe("wildcard"); + }); + + it("explicitly configured room should be usable to override DM classification", () => { + const result = resolveMatrixRoomConfig({ + rooms: { + "!configured-room:example.org": { allow: true }, + "*": { allow: true }, + }, + roomId: "!configured-room:example.org", + aliases: [], + }); + // handler.ts checks: matchSource === "direct" -> this IS "direct", so override is safe + expect(result.matchSource).toBe("direct"); + }); + }); }); From a40c29b11a0246271d49a33e142e742c7f0e23da Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 9 Mar 2026 10:26:17 +0530 Subject: [PATCH 213/311] Fix cron text announce delivery for Telegram targets (#40575) Merged via squash. Prepared head SHA: 54b1513c78613bddd8cae16ab2d617788a0dacb6 Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + ...onse-has-heartbeat-ok-but-includes.test.ts | 110 ++---- ...gent.direct-delivery-core-channels.test.ts | 158 ++++++++ ...agent.direct-delivery-forum-topics.test.ts | 12 +- src/cron/isolated-agent.mocks.ts | 4 + ...p-recipient-besteffortdeliver-true.test.ts | 233 ++++++++---- src/cron/isolated-agent.test-setup.ts | 2 + .../delivery-dispatch.double-announce.test.ts | 61 +-- src/cron/isolated-agent/delivery-dispatch.ts | 351 +++++++----------- 9 files changed, 513 insertions(+), 419 deletions(-) create mode 100644 src/cron/isolated-agent.direct-delivery-core-channels.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a5dec04567b..dfe012b906540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung. - Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. - Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. +- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus. ## 2026.3.7 diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index 7b65101e8da51..023c1e9eedceb 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -4,6 +4,7 @@ import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.j import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import type { CliDeps } from "../cli/deps.js"; +import { callGateway } from "../gateway/call.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js"; import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; @@ -137,7 +138,7 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("handles media heartbeat delivery and announce cleanup modes", async () => { + it("handles media heartbeat delivery and last-target text delivery", async () => { await withTempHome(async (home) => { const { storePath, deps } = await createTelegramDeliveryFixture(home); @@ -185,14 +186,18 @@ describe("runCronIsolatedAgentTurn", () => { }); expect(keepRes.status).toBe("ok"); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - const keepArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as - | { cleanup?: "keep" | "delete" } - | undefined; - expect(keepArgs?.cleanup).toBe("keep"); - expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + expect(keepRes.delivered).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + expect(deps.sendMessageTelegram).toHaveBeenCalledWith( + "123", + "HEARTBEAT_OK 🦞", + expect.objectContaining({ accountId: undefined }), + ); + vi.mocked(deps.sendMessageTelegram).mockClear(); vi.mocked(runSubagentAnnounceFlow).mockClear(); + vi.mocked(callGateway).mockClear(); const deleteRes = await runCronIsolatedAgentTurn({ cfg, @@ -211,12 +216,25 @@ describe("runCronIsolatedAgentTurn", () => { }); expect(deleteRes.status).toBe("ok"); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - const deleteArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as - | { cleanup?: "keep" | "delete" } - | undefined; - expect(deleteArgs?.cleanup).toBe("delete"); - expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + expect(deleteRes.delivered).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + expect(deps.sendMessageTelegram).toHaveBeenCalledWith( + "123", + "HEARTBEAT_OK 🦞", + expect.objectContaining({ accountId: undefined }), + ); + expect(callGateway).toHaveBeenCalledTimes(1); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "sessions.delete", + params: expect.objectContaining({ + key: "agent:main:cron:job-1", + deleteTranscript: true, + emitLifecycleHooks: false, + }), + }), + ); }); }); @@ -243,70 +261,4 @@ describe("runCronIsolatedAgentTurn", () => { expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); }); }); - - it("uses a unique announce childRunId for each cron run", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home, { - lastProvider: "telegram", - lastChannel: "telegram", - lastTo: "123", - }); - const deps: CliDeps = { - sendMessageSlack: vi.fn(), - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn(), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "final summary" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const cfg = makeCfg(home, storePath); - const job = makeJob({ kind: "agentTurn", message: "do it" }); - job.delivery = { mode: "announce", channel: "last" }; - - const nowSpy = vi.spyOn(Date, "now"); - let now = Date.now(); - nowSpy.mockImplementation(() => now); - try { - await runCronIsolatedAgentTurn({ - cfg, - deps, - job, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - now += 5; - await runCronIsolatedAgentTurn({ - cfg, - deps, - job, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - } finally { - nowSpy.mockRestore(); - } - - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(2); - const firstArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as - | { childRunId?: string } - | undefined; - const secondArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[1]?.[0] as - | { childRunId?: string } - | undefined; - expect(firstArgs?.childRunId).toBeTruthy(); - expect(secondArgs?.childRunId).toBeTruthy(); - expect(secondArgs?.childRunId).not.toBe(firstArgs?.childRunId); - }); - }); }); diff --git a/src/cron/isolated-agent.direct-delivery-core-channels.test.ts b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts new file mode 100644 index 0000000000000..1950e361068d7 --- /dev/null +++ b/src/cron/isolated-agent.direct-delivery-core-channels.test.ts @@ -0,0 +1,158 @@ +import "./isolated-agent.mocks.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; +import { discordOutbound } from "../channels/plugins/outbound/discord.js"; +import { imessageOutbound } from "../channels/plugins/outbound/imessage.js"; +import { signalOutbound } from "../channels/plugins/outbound/signal.js"; +import { slackOutbound } from "../channels/plugins/outbound/slack.js"; +import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; +import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js"; +import type { CliDeps } from "../cli/deps.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createCliDeps, mockAgentPayloads } from "./isolated-agent.delivery.test-helpers.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStore, +} from "./isolated-agent.test-harness.js"; +import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js"; + +type ChannelCase = { + name: string; + channel: "slack" | "discord" | "whatsapp" | "imessage"; + to: string; + sendKey: keyof Pick< + CliDeps, + "sendMessageSlack" | "sendMessageDiscord" | "sendMessageWhatsApp" | "sendMessageIMessage" + >; + expectedTo: string; +}; + +const CASES: ChannelCase[] = [ + { + name: "Slack", + channel: "slack", + to: "channel:C12345", + sendKey: "sendMessageSlack", + expectedTo: "channel:C12345", + }, + { + name: "Discord", + channel: "discord", + to: "channel:789", + sendKey: "sendMessageDiscord", + expectedTo: "channel:789", + }, + { + name: "WhatsApp", + channel: "whatsapp", + to: "+15551234567", + sendKey: "sendMessageWhatsApp", + expectedTo: "+15551234567", + }, + { + name: "iMessage", + channel: "imessage", + to: "friend@example.com", + sendKey: "sendMessageIMessage", + expectedTo: "friend@example.com", + }, +]; + +async function runExplicitAnnounceTurn(params: { + home: string; + storePath: string; + deps: CliDeps; + channel: ChannelCase["channel"]; + to: string; +}) { + return await runCronIsolatedAgentTurn({ + cfg: makeCfg(params.home, params.storePath), + deps: params.deps, + job: { + ...makeJob({ kind: "agentTurn", message: "do it" }), + delivery: { + mode: "announce", + channel: params.channel, + to: params.to, + }, + }, + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); +} + +describe("runCronIsolatedAgentTurn core-channel direct delivery", () => { + beforeEach(() => { + setupIsolatedAgentTurnMocks(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + source: "test", + }, + { + pluginId: "signal", + plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound }), + source: "test", + }, + { + pluginId: "slack", + plugin: createOutboundTestPlugin({ id: "slack", outbound: slackOutbound }), + source: "test", + }, + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ id: "discord", outbound: discordOutbound }), + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createOutboundTestPlugin({ id: "whatsapp", outbound: whatsappOutbound }), + source: "test", + }, + { + pluginId: "imessage", + plugin: createOutboundTestPlugin({ id: "imessage", outbound: imessageOutbound }), + source: "test", + }, + ]), + ); + }); + + for (const testCase of CASES) { + it(`routes ${testCase.name} text-only announce delivery through the outbound adapter`, async () => { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const deps = createCliDeps(); + mockAgentPayloads([{ text: "hello from cron" }]); + + const res = await runExplicitAnnounceTurn({ + home, + storePath, + deps, + channel: testCase.channel, + to: testCase.to, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(true); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + + const sendFn = deps[testCase.sendKey]; + expect(sendFn).toHaveBeenCalledTimes(1); + expect(sendFn).toHaveBeenCalledWith( + testCase.expectedTo, + "hello from cron", + expect.any(Object), + ); + }); + }); + } +}); diff --git a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts index 7f7df20941875..836369fedb6cd 100644 --- a/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts +++ b/src/cron/isolated-agent.direct-delivery-forum-topics.test.ts @@ -48,12 +48,12 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => { }); expect(plainRes.status).toBe("ok"); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as - | { expectsCompletionMessage?: boolean } - | undefined; - expect(announceArgs?.expectsCompletionMessage).toBe(true); - expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + expect(plainRes.delivered).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expectDirectTelegramDelivery(deps, { + chatId: "123", + text: "plain message", + }); }); }); }); diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 913f5ab74d4b8..72e031dc3f4c4 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -26,5 +26,9 @@ vi.mock("../agents/subagent-announce.js", () => ({ runSubagentAnnounceFlow: vi.fn(), })); +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(), +})); + export const makeIsolatedAgentJob = makeIsolatedAgentJobFixture; export const makeIsolatedAgentParams = makeIsolatedAgentParamsFixture; diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index bc763a7a588ac..6b2ab85739a20 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -104,7 +104,7 @@ async function expectStructuredTelegramFailure(params: { ); } -async function runAnnounceFlowResult(bestEffort: boolean) { +async function runTelegramDeliveryResult(bestEffort: boolean) { let outcome: | { res: Awaited>; @@ -113,7 +113,6 @@ async function runAnnounceFlowResult(bestEffort: boolean) { | undefined; await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { mockAgentPayloads([{ text: "hello from cron" }]); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); const res = await runTelegramAnnounceTurn({ home, storePath, @@ -128,12 +127,12 @@ async function runAnnounceFlowResult(bestEffort: boolean) { outcome = { res, deps }; }); if (!outcome) { - throw new Error("announce flow did not produce an outcome"); + throw new Error("telegram delivery did not produce an outcome"); } return outcome; } -async function runSignalAnnounceFlowResult(bestEffort: boolean) { +async function runSignalDeliveryResult(bestEffort: boolean) { let outcome: | { res: Awaited>; @@ -144,7 +143,6 @@ async function runSignalAnnounceFlowResult(bestEffort: boolean) { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps = createCliDeps(); mockAgentPayloads([{ text: "hello from cron" }]); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); const res = await runCronIsolatedAgentTurn({ cfg: makeCfg(home, storePath, { channels: { signal: {} }, @@ -166,12 +164,12 @@ async function runSignalAnnounceFlowResult(bestEffort: boolean) { outcome = { res, deps }; }); if (!outcome) { - throw new Error("signal announce flow did not produce an outcome"); + throw new Error("signal delivery did not produce an outcome"); } return outcome; } -async function assertExplicitTelegramTargetAnnounce(params: { +async function assertExplicitTelegramTargetDelivery(params: { home: string; storePath: string; deps: CliDeps; @@ -186,22 +184,11 @@ async function assertExplicitTelegramTargetAnnounce(params: { }); expectDeliveredOk(res); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as - | { - requesterOrigin?: { channel?: string; to?: string }; - roundOneReply?: string; - bestEffortDeliver?: boolean; - } - | undefined; - expect(announceArgs?.requesterOrigin?.channel).toBe("telegram"); - expect(announceArgs?.requesterOrigin?.to).toBe("123"); - expect(announceArgs?.roundOneReply).toBe(params.expectedText); - expect(announceArgs?.bestEffortDeliver).toBe(false); - expect((announceArgs as { expectsCompletionMessage?: boolean })?.expectsCompletionMessage).toBe( - true, - ); - expect(params.deps.sendMessageTelegram).not.toHaveBeenCalled(); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expectDirectTelegramDelivery(params.deps, { + chatId: "123", + text: params.expectedText, + }); } describe("runCronIsolatedAgentTurn", () => { @@ -209,9 +196,9 @@ describe("runCronIsolatedAgentTurn", () => { setupIsolatedAgentTurnMocks(); }); - it("announces explicit targets with direct and final-payload text", async () => { + it("delivers explicit targets with direct and final-payload text", async () => { await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { - await assertExplicitTelegramTargetAnnounce({ + await assertExplicitTelegramTargetDelivery({ home, storePath, deps, @@ -219,7 +206,7 @@ describe("runCronIsolatedAgentTurn", () => { expectedText: "hello from cron", }); vi.clearAllMocks(); - await assertExplicitTelegramTargetAnnounce({ + await assertExplicitTelegramTargetDelivery({ home, storePath, deps, @@ -229,7 +216,7 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("routes announce injection to the delivery-target session key", async () => { + it("delivers explicit targets directly with per-channel-peer session scoping", async () => { await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { mockAgentPayloads([{ text: "hello from cron" }]); @@ -254,17 +241,12 @@ describe("runCronIsolatedAgentTurn", () => { lane: "cron", }); - expect(res.status).toBe("ok"); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as - | { - requesterSessionKey?: string; - requesterOrigin?: { channel?: string; to?: string }; - } - | undefined; - expect(announceArgs?.requesterSessionKey).toBe("agent:main:telegram:direct:123"); - expect(announceArgs?.requesterOrigin?.channel).toBe("telegram"); - expect(announceArgs?.requesterOrigin?.to).toBe("123"); + expectDeliveredOk(res); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expectDirectTelegramDelivery(deps, { + chatId: "123", + text: "hello from cron", + }); }); }); @@ -359,12 +341,42 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("falls back to direct delivery when announce reports false and best-effort is disabled", async () => { + it("reports not-delivered when text direct delivery fails and best-effort is enabled", async () => { + await withTelegramAnnounceFixture( + async ({ home, storePath, deps }) => { + mockAgentPayloads([{ text: "hello from cron" }]); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + bestEffort: true, + }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + }, + { + deps: { + sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), + }, + }, + ); + }); + + it("delivers text directly when best-effort is disabled", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps = createCliDeps(); mockAgentPayloads([{ text: "hello from cron" }]); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false); const res = await runTelegramAnnounceTurn({ home, @@ -378,61 +390,122 @@ describe("runCronIsolatedAgentTurn", () => { }, }); - // When announce delivery fails, the direct-delivery fallback fires - // so the message still reaches the target channel. expect(res.status).toBe("ok"); expect(res.delivered).toBe(true); expect(res.deliveryAttempted).toBe(true); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expectDirectTelegramDelivery(deps, { + chatId: "123", + text: "hello from cron", + }); }); }); - it("falls back to direct delivery when announce reports false and best-effort is enabled", async () => { - const { res, deps } = await runAnnounceFlowResult(true); + it("returns error when text direct delivery fails and best-effort is disabled", async () => { + await withTelegramAnnounceFixture( + async ({ home, storePath, deps }) => { + mockAgentPayloads([{ text: "hello from cron" }]); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + bestEffort: false, + }, + }); + + expect(res.status).toBe("error"); + expect(res.delivered).toBeUndefined(); + expect(res.deliveryAttempted).toBe(true); + expect(res.error).toContain("boom"); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + }, + { + deps: { + sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), + }, + }, + ); + }); + + it("retries transient text direct delivery failures before succeeding", async () => { + const previousFastMode = process.env.OPENCLAW_TEST_FAST; + process.env.OPENCLAW_TEST_FAST = "1"; + try { + await withTelegramAnnounceFixture( + async ({ home, storePath, deps }) => { + mockAgentPayloads([{ text: "hello from cron" }]); + + const res = await runTelegramAnnounceTurn({ + home, + storePath, + deps, + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + bestEffort: false, + }, + }); + + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(true); + expect(res.deliveryAttempted).toBe(true); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(2); + expect(deps.sendMessageTelegram).toHaveBeenLastCalledWith( + "123", + "hello from cron", + expect.objectContaining({ cfg: expect.any(Object) }), + ); + }, + { + deps: { + sendMessageTelegram: vi + .fn() + .mockRejectedValueOnce(new Error("UNAVAILABLE: temporary network error")) + .mockResolvedValue({ messageId: 7, chatId: "123", text: "hello from cron" }), + }, + }, + ); + } finally { + if (previousFastMode === undefined) { + delete process.env.OPENCLAW_TEST_FAST; + } else { + process.env.OPENCLAW_TEST_FAST = previousFastMode; + } + } + }); + + it("delivers text directly when best-effort is enabled", async () => { + const { res, deps } = await runTelegramDeliveryResult(true); expect(res.status).toBe("ok"); expect(res.delivered).toBe(true); expect(res.deliveryAttempted).toBe(true); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expectDirectTelegramDelivery(deps, { + chatId: "123", + text: "hello from cron", + }); }); - it("falls back to direct delivery for signal when announce reports false and best-effort is enabled", async () => { - const { res, deps } = await runSignalAnnounceFlowResult(true); + it("delivers text directly for signal when best-effort is enabled", async () => { + const { res, deps } = await runSignalDeliveryResult(true); expect(res.status).toBe("ok"); expect(res.delivered).toBe(true); expect(res.deliveryAttempted).toBe(true); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); expect(deps.sendMessageSignal).toHaveBeenCalledTimes(1); - }); - - it("falls back to direct delivery when announce flow throws and best-effort is disabled", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); - const deps = createCliDeps(); - mockAgentPayloads([{ text: "hello from cron" }]); - vi.mocked(runSubagentAnnounceFlow).mockRejectedValueOnce( - new Error("gateway closed (1008): pairing required"), - ); - - const res = await runTelegramAnnounceTurn({ - home, - storePath, - deps, - delivery: { - mode: "announce", - channel: "telegram", - to: "123", - bestEffort: false, - }, - }); - - // When announce throws (e.g. "pairing required"), the direct-delivery - // fallback fires so the message still reaches the target channel. - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(true); - expect(res.deliveryAttempted).toBe(true); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); - }); + expect(deps.sendMessageSignal).toHaveBeenCalledWith( + "+15551234567", + "hello from cron", + expect.any(Object), + ); }); it("ignores structured direct delivery failures when best-effort is enabled", async () => { diff --git a/src/cron/isolated-agent.test-setup.ts b/src/cron/isolated-agent.test-setup.ts index 6a776b323d9e9..e6357531ad361 100644 --- a/src/cron/isolated-agent.test-setup.ts +++ b/src/cron/isolated-agent.test-setup.ts @@ -4,6 +4,7 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; import { signalOutbound } from "../channels/plugins/outbound/signal.js"; import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; +import { callGateway } from "../gateway/call.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -14,6 +15,7 @@ export function setupIsolatedAgentTurnMocks(params?: { fast?: boolean }): void { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(runSubagentAnnounceFlow).mockReset().mockResolvedValue(true); + vi.mocked(callGateway).mockReset().mockResolvedValue({ ok: true, deleted: true }); setActivePluginRegistry( createTestRegistry([ { diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index abaf1ae5349bf..f9a7d90a276df 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -1,10 +1,10 @@ /** * Tests for the double-announce bug in cron delivery dispatch. * - * Bug: early return paths in deliverViaAnnounce (active subagent suppression + * Bug: early return paths in text finalization (active subagent suppression * and stale interim message suppression) returned without setting * deliveryAttempted = true. The timer saw deliveryAttempted = false and - * fired enqueueSystemEvent as a fallback, causing a second announcement. + * fired enqueueSystemEvent as a fallback, causing a second delivery. * * Fix: both early return paths now set deliveryAttempted = true before * returning so the timer correctly skips the system-event fallback. @@ -14,23 +14,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // --- Module mocks (must be hoisted before imports) --- -vi.mock("../../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true), -})); - vi.mock("../../agents/subagent-registry.js", () => ({ countActiveDescendantRuns: vi.fn().mockReturnValue(0), })); -vi.mock("../../config/sessions.js", () => ({ - resolveAgentMainSessionKey: vi.fn().mockReturnValue("agent:main"), -})); - -vi.mock("../../infra/outbound/outbound-session.js", () => ({ - resolveOutboundSessionRoute: vi.fn().mockResolvedValue(null), - ensureOutboundSessionEntry: vi.fn().mockResolvedValue(undefined), -})); - vi.mock("../../infra/outbound/deliver.js", () => ({ deliverOutboundPayloads: vi.fn().mockResolvedValue([{ ok: true }]), })); @@ -58,9 +45,9 @@ vi.mock("./subagent-followup.js", () => ({ waitForDescendantSubagentSummary: vi.fn().mockResolvedValue(undefined), })); -import { runSubagentAnnounceFlow } from "../../agents/subagent-announce.js"; // Import after mocks import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; +import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; import { dispatchCronDelivery } from "./delivery-dispatch.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; @@ -145,7 +132,6 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(undefined); vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); }); it("early return (active subagent) sets deliveryAttempted=true so timer skips enqueueSystemEvent", async () => { @@ -173,7 +159,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { ).toBe(false); // No announce should have been attempted (subagents still running) - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); }); it("early return (stale interim suppression) sets deliveryAttempted=true so timer skips enqueueSystemEvent", async () => { @@ -204,45 +190,42 @@ describe("dispatchCronDelivery — double-announce guard", () => { }), ).toBe(false); - // No announce or direct delivery should have been sent (stale interim suppressed) - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + // No direct delivery should have been sent (stale interim suppressed) + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); }); - it("consolidates descendant output into the cron announce path", async () => { + it("consolidates descendant output into the final direct delivery", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(true); vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue( "Detailed child result, everything finished successfully.", ); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); const params = makeBaseParams({ synthesizedText: "on it" }); const state = await dispatchCronDelivery(params); expect(state.deliveryAttempted).toBe(true); expect(state.delivered).toBe(true); - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - expect(runSubagentAnnounceFlow).toHaveBeenCalledWith( + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ - roundOneReply: "Detailed child result, everything finished successfully.", - expectsCompletionMessage: true, - announceType: "cron job", + channel: "telegram", + to: "123456", + payloads: [{ text: "Detailed child result, everything finished successfully." }], }), ); }); - it("normal announce success delivers exactly once and sets deliveryAttempted=true", async () => { + it("normal text delivery sends exactly once and sets deliveryAttempted=true", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); - vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(true); const params = makeBaseParams({ synthesizedText: "Morning briefing complete." }); const state = await dispatchCronDelivery(params); expect(state.deliveryAttempted).toBe(true); expect(state.delivered).toBe(true); - // Announce called exactly once - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); // Timer should not fire enqueueSystemEvent (delivered=true) expect( @@ -257,13 +240,9 @@ describe("dispatchCronDelivery — double-announce guard", () => { ).toBe(false); }); - it("announce failure falls back to direct delivery exactly once (no double-deliver)", async () => { + it("text delivery fires exactly once (no double-deliver)", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); - // Announce fails: runSubagentAnnounceFlow returns false - vi.mocked(runSubagentAnnounceFlow).mockResolvedValue(false); - - const { deliverOutboundPayloads } = await import("../../infra/outbound/deliver.js"); vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); const params = makeBaseParams({ synthesizedText: "Briefing ready." }); @@ -273,23 +252,17 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(state.deliveryAttempted).toBe(true); expect(state.delivered).toBe(true); - // Announce was tried exactly once - expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - - // Direct fallback fired exactly once (not zero, not twice) - // This ensures one delivery total reaches the user, not two expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); }); - it("no delivery requested means deliveryAttempted stays false and runSubagentAnnounceFlow not called", async () => { + it("no delivery requested means deliveryAttempted stays false and no delivery is sent", async () => { const params = makeBaseParams({ synthesizedText: "Task done.", deliveryRequested: false, }); const state = await dispatchCronDelivery(params); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - // deliveryAttempted starts false (skipMessagingToolDelivery=false) and nothing runs + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); expect(state.deliveryAttempted).toBe(false); }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index fffa5fcb8b88d..a3a98b245d07b 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -1,16 +1,12 @@ -import { runSubagentAnnounceFlow } from "../../agents/subagent-announce.js"; import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveAgentMainSessionKey } from "../../config/sessions.js"; +import { callGateway } from "../../gateway/call.js"; +import { sleepWithAbort } from "../../infra/backoff.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; -import { - ensureOutboundSessionEntry, - resolveOutboundSessionRoute, -} from "../../infra/outbound/outbound-session.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { logWarn } from "../../logger.js"; import type { CronJob, CronRunTelemetry } from "../types.js"; @@ -71,53 +67,6 @@ export function resolveCronDeliveryBestEffort(job: CronJob): boolean { return false; } -async function resolveCronAnnounceSessionKey(params: { - cfg: OpenClawConfig; - agentId: string; - fallbackSessionKey: string; - delivery: { - channel: NonNullable; - to?: string; - accountId?: string; - threadId?: string | number; - }; -}): Promise { - const to = params.delivery.to?.trim(); - if (!to) { - return params.fallbackSessionKey; - } - try { - const route = await resolveOutboundSessionRoute({ - cfg: params.cfg, - channel: params.delivery.channel, - agentId: params.agentId, - accountId: params.delivery.accountId, - target: to, - threadId: params.delivery.threadId, - }); - const resolved = route?.sessionKey?.trim(); - if (route && resolved) { - // Ensure the session entry exists so downstream announce / queue delivery - // can look up channel metadata (lastChannel, to, sessionId). Named agents - // may not have a session entry for this target yet, causing announce - // delivery to silently fail (#32432). - await ensureOutboundSessionEntry({ - cfg: params.cfg, - agentId: params.agentId, - channel: params.delivery.channel, - accountId: params.delivery.accountId, - route, - }).catch(() => { - // Best-effort: don't block delivery on session entry creation. - }); - return resolved; - } - } catch { - // Fall back to main session routing if announce session resolution fails. - } - return params.fallbackSessionKey; -} - export type SuccessfulDeliveryTarget = Extract; type DispatchCronDeliveryParams = { @@ -160,6 +109,86 @@ export type DispatchCronDeliveryState = { deliveryPayloads: ReplyPayload[]; }; +const TRANSIENT_DIRECT_CRON_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_DIRECT_CRON_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 summarizeDirectCronDeliveryError(error: unknown): string { + if (error instanceof Error) { + return error.message || "error"; + } + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error) || String(error); + } catch { + return String(error); + } +} + +function isTransientDirectCronDeliveryError(error: unknown): boolean { + const message = summarizeDirectCronDeliveryError(error); + if (!message) { + return false; + } + if (PERMANENT_DIRECT_CRON_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) { + return false; + } + return TRANSIENT_DIRECT_CRON_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message)); +} + +function resolveDirectCronRetryDelaysMs(): readonly number[] { + return process.env.OPENCLAW_TEST_FAST === "1" ? [8, 16, 32] : [5_000, 10_000, 20_000]; +} + +async function retryTransientDirectCronDelivery(params: { + jobId: string; + signal?: AbortSignal; + run: () => Promise; +}): Promise { + const retryDelaysMs = resolveDirectCronRetryDelaysMs(); + let retryIndex = 0; + for (;;) { + if (params.signal?.aborted) { + throw new Error("cron delivery aborted"); + } + try { + return await params.run(); + } catch (err) { + const delayMs = retryDelaysMs[retryIndex]; + if (delayMs == null || !isTransientDirectCronDeliveryError(err) || params.signal?.aborted) { + throw err; + } + const nextAttempt = retryIndex + 2; + const maxAttempts = retryDelaysMs.length + 1; + logWarn( + `[cron:${params.jobId}] transient direct announce delivery failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1000)}s: ${summarizeDirectCronDeliveryError(err)}`, + ); + retryIndex += 1; + await sleepWithAbort(delayMs, params.signal); + } + } +} + export async function dispatchCronDelivery( params: DispatchCronDeliveryParams, ): Promise { @@ -172,12 +201,6 @@ export async function dispatchCronDelivery( // Keep this strict so timer fallback can safely decide whether to wake main. let delivered = params.skipMessagingToolDelivery; let deliveryAttempted = params.skipMessagingToolDelivery; - // Tracks whether `runSubagentAnnounceFlow` was actually called. Early - // returns from `deliverViaAnnounce` (active subagents, interim suppression, - // SILENT_REPLY_TOKEN) are intentional suppressions — not delivery failures — - // so the direct-delivery fallback must only fire when the announce send was - // actually attempted and failed. - let announceDeliveryWasAttempted = false; const failDeliveryTarget = (error: string) => params.withRunSession({ status: "error", @@ -191,6 +214,7 @@ export async function dispatchCronDelivery( const deliverViaDirect = async ( delivery: SuccessfulDeliveryTarget, + options?: { retryTransient?: boolean }, ): Promise => { const identity = resolveAgentOutboundIdentity(params.cfgWithAgentDefaults, params.agentId); try { @@ -217,19 +241,27 @@ export async function dispatchCronDelivery( agentId: params.agentId, sessionKey: params.agentSessionKey, }); - const deliveryResults = await deliverOutboundPayloads({ - cfg: params.cfgWithAgentDefaults, - channel: delivery.channel, - to: delivery.to, - accountId: delivery.accountId, - threadId: delivery.threadId, - payloads: payloadsForDelivery, - session: deliverySession, - identity, - bestEffort: params.deliveryBestEffort, - deps: createOutboundSendDeps(params.deps), - abortSignal: params.abortSignal, - }); + const runDelivery = async () => + await deliverOutboundPayloads({ + cfg: params.cfgWithAgentDefaults, + channel: delivery.channel, + to: delivery.to, + accountId: delivery.accountId, + threadId: delivery.threadId, + payloads: payloadsForDelivery, + session: deliverySession, + identity, + bestEffort: params.deliveryBestEffort, + deps: createOutboundSendDeps(params.deps), + abortSignal: params.abortSignal, + }); + const deliveryResults = options?.retryTransient + ? await retryTransientDirectCronDelivery({ + jobId: params.job.id, + signal: params.abortSignal, + run: runDelivery, + }) + : await runDelivery(); delivered = deliveryResults.length > 0; return null; } catch (err) { @@ -247,31 +279,31 @@ export async function dispatchCronDelivery( } }; - const deliverViaAnnounce = async ( + const finalizeTextDelivery = async ( delivery: SuccessfulDeliveryTarget, ): Promise => { + const cleanupDirectCronSessionIfNeeded = async (): Promise => { + if (!params.job.deleteAfterRun) { + return; + } + try { + await callGateway({ + method: "sessions.delete", + params: { + key: params.agentSessionKey, + deleteTranscript: true, + emitLifecycleHooks: false, + }, + timeoutMs: 10_000, + }); + } catch { + // Best-effort; direct delivery result should still be returned. + } + }; + if (!synthesizedText) { return null; } - const announceMainSessionKey = resolveAgentMainSessionKey({ - cfg: params.cfg, - agentId: params.agentId, - }); - const announceSessionKey = await resolveCronAnnounceSessionKey({ - cfg: params.cfgWithAgentDefaults, - agentId: params.agentId, - fallbackSessionKey: announceMainSessionKey, - delivery: { - channel: delivery.channel, - to: delivery.to, - accountId: delivery.accountId, - threadId: delivery.threadId, - }, - }); - const taskLabel = - typeof params.job.name === "string" && params.job.name.trim() - ? params.job.name.trim() - : `cron:${params.job.id}`; const initialSynthesizedText = synthesizedText.trim(); let activeSubagentRuns = countActiveDescendantRuns(params.agentSessionKey); const expectedSubagentFollowup = expectsSubagentFollowup(initialSynthesizedText); @@ -357,84 +389,19 @@ export async function dispatchCronDelivery( ...params.telemetry, }); } - try { - if (params.isAborted()) { - return params.withRunSession({ - status: "error", - error: params.abortReason(), - deliveryAttempted, - ...params.telemetry, - }); - } - deliveryAttempted = true; - announceDeliveryWasAttempted = true; - const didAnnounce = await runSubagentAnnounceFlow({ - childSessionKey: params.agentSessionKey, - childRunId: `${params.job.id}:${params.runSessionId}:${params.runStartedAt}`, - requesterSessionKey: announceSessionKey, - requesterOrigin: { - channel: delivery.channel, - to: delivery.to, - accountId: delivery.accountId, - threadId: delivery.threadId, - }, - requesterDisplayKey: announceSessionKey, - task: taskLabel, - timeoutMs: params.timeoutMs, - cleanup: params.job.deleteAfterRun ? "delete" : "keep", - roundOneReply: synthesizedText, - // Cron output is a finished completion message: send it directly to the - // target channel via the completion-direct-send path rather than injecting - // a trigger message into the (likely idle) main agent session. - expectsCompletionMessage: true, - // Keep delivery outcome truthful for cron state: if outbound send fails, - // announce flow must report false so caller can apply best-effort policy. - bestEffortDeliver: false, - waitForCompletion: false, - startedAt: params.runStartedAt, - endedAt: params.runEndedAt, - outcome: { status: "ok" }, - announceType: "cron job", - signal: params.abortSignal, + if (params.isAborted()) { + return params.withRunSession({ + status: "error", + error: params.abortReason(), + deliveryAttempted, + ...params.telemetry, }); - if (didAnnounce) { - delivered = true; - } else { - // Announce delivery failed but the agent execution itself succeeded. - // Return ok so the job isn't penalized for a transient delivery issue - // (e.g. "pairing required" when no active client session exists). - // Delivery failure is tracked separately via delivered/deliveryAttempted. - const message = "cron announce delivery failed"; - logWarn(`[cron:${params.job.id}] ${message}`); - if (!params.deliveryBestEffort) { - return params.withRunSession({ - status: "ok", - summary, - outputText, - error: message, - delivered: false, - deliveryAttempted, - ...params.telemetry, - }); - } - } - } catch (err) { - // Same as above: announce delivery errors should not mark a successful - // agent execution as failed. - logWarn(`[cron:${params.job.id}] ${String(err)}`); - if (!params.deliveryBestEffort) { - return params.withRunSession({ - status: "ok", - summary, - outputText, - error: String(err), - delivered: false, - deliveryAttempted, - ...params.telemetry, - }); - } } - return null; + try { + return await deliverViaDirect(delivery, { retryTransient: true }); + } finally { + await cleanupDirectCronSessionIfNeeded(); + } }; if ( @@ -472,14 +439,9 @@ export async function dispatchCronDelivery( }; } - // Route text-only cron announce output back through the main session so it - // follows the same system-message injection path as subagent completions. - // Keep direct outbound delivery only for structured payloads (media/channel - // data), which cannot be represented by the shared announce flow. - // - // Forum/topic targets should also use direct delivery. Announce flow can - // be swallowed by ANNOUNCE_SKIP/NO_REPLY in the target agent turn, which - // silently drops cron output for topic-bound sessions. + // Finalize descendant/subagent output first for text-only cron runs, then + // send through the real outbound adapter so delivered=true always reflects + // an actual channel send instead of internal announce routing. const useDirectDelivery = params.deliveryPayloadHasStructuredContent || params.resolvedDelivery.threadId != null; if (useDirectDelivery) { @@ -496,41 +458,10 @@ export async function dispatchCronDelivery( }; } } else { - const announceResult = await deliverViaAnnounce(params.resolvedDelivery); - // Fall back to direct delivery only when the announce send was actually - // attempted and failed. Early returns from deliverViaAnnounce (active - // subagents, interim suppression, SILENT_REPLY_TOKEN) are intentional - // suppressions that must NOT trigger direct delivery — doing so would - // bypass the suppression guard and leak partial/stale content. - if (announceDeliveryWasAttempted && !delivered && !params.isAborted()) { - const directFallback = await deliverViaDirect(params.resolvedDelivery); - if (directFallback) { - return { - result: directFallback, - delivered, - deliveryAttempted, - summary, - outputText, - synthesizedText, - deliveryPayloads, - }; - } - // If direct delivery succeeded (returned null without error), - // `delivered` has been set to true by deliverViaDirect. - if (delivered) { - return { - delivered, - deliveryAttempted, - summary, - outputText, - synthesizedText, - deliveryPayloads, - }; - } - } - if (announceResult) { + const finalizedTextResult = await finalizeTextDelivery(params.resolvedDelivery); + if (finalizedTextResult) { return { - result: announceResult, + result: finalizedTextResult, delivered, deliveryAttempted, summary, From 41450187ddf4e6f5bbbbd296d56c599d0612bbef Mon Sep 17 00:00:00 2001 From: GazeKingNuWu Date: Mon, 9 Mar 2026 13:16:25 +0800 Subject: [PATCH 214/311] fix: clear plugin discovery cache after plugin installation (openclaw#39752) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: GazeKingNuWu <264914544+GazeKingNuWu@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../onboarding/plugin-install.test.ts | 34 ++++++++++++++++++- src/commands/onboarding/plugin-install.ts | 2 ++ src/cron/isolated-agent/run.test-harness.ts | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe012b906540..f60c95a0c3cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. - Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092) +- Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu. - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index b04769dcb54dc..2be78d9a6fce5 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -49,12 +49,21 @@ vi.mock("../../plugins/loader.js", () => ({ loadOpenClawPlugins: vi.fn(), })); +const clearPluginDiscoveryCache = vi.fn(); +vi.mock("../../plugins/discovery.js", () => ({ + clearPluginDiscoveryCache: () => clearPluginDiscoveryCache(), +})); + import fs from "node:fs"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { loadOpenClawPlugins } from "../../plugins/loader.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { makePrompter, makeRuntime } from "./__tests__/test-utils.js"; -import { ensureOnboardingPluginInstalled } from "./plugin-install.js"; +import { + ensureOnboardingPluginInstalled, + reloadOnboardingPluginRegistry, +} from "./plugin-install.js"; const baseEntry: ChannelPluginCatalogEntry = { id: "zalo", @@ -236,4 +245,27 @@ describe("ensureOnboardingPluginInstalled", () => { expect(note).toHaveBeenCalled(); expect(runtime.error).not.toHaveBeenCalled(); }); + + it("clears discovery cache before reloading the onboarding plugin registry", () => { + const runtime = makeRuntime(); + const cfg: OpenClawConfig = {}; + + reloadOnboardingPluginRegistry({ + cfg, + runtime, + workspaceDir: "/tmp/openclaw-workspace", + }); + + expect(clearPluginDiscoveryCache).toHaveBeenCalledTimes(1); + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + workspaceDir: "/tmp/openclaw-workspace", + cache: false, + }), + ); + expect(clearPluginDiscoveryCache.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(loadOpenClawPlugins).mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); }); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 14245461e21d2..b4aabc0664667 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -9,6 +9,7 @@ import { findBundledPluginSourceInMap, resolveBundledPluginSources, } from "../../plugins/bundled-sources.js"; +import { clearPluginDiscoveryCache } from "../../plugins/discovery.js"; import { enablePluginInConfig } from "../../plugins/enable.js"; import { installPluginFromNpmSpec } from "../../plugins/install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js"; @@ -224,6 +225,7 @@ export function reloadOnboardingPluginRegistry(params: { runtime: RuntimeEnv; workspaceDir?: string; }): void { + clearPluginDiscoveryCache(); const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const log = createSubsystemLogger("plugins"); diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index c47fbec9f88d4..6a1fa1c3dff92 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -64,6 +64,7 @@ vi.mock("../../agents/skills/refresh.js", () => ({ })); vi.mock("../../agents/workspace.js", () => ({ + DEFAULT_IDENTITY_FILENAME: "IDENTITY.md", ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }), })); From 41eef15cdc477a1a6207b89a65a74bac6f6d4e0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:24:09 +0000 Subject: [PATCH 215/311] test: fix windows secrets runtime ci --- src/secrets/runtime.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 00524153b5fa6..1d395ee44841c 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -42,6 +42,8 @@ describe("secrets runtime snapshot", () => { clearSecretsRuntimeSnapshot(); }); + const allowInsecureTempSecretFile = process.platform === "win32"; + it("resolves env refs for config and auth profiles", async () => { const config = asConfig({ agents: { @@ -567,7 +569,12 @@ describe("secrets runtime snapshot", () => { config: asConfig({ secrets: { providers: { - default: { source: "file", path: secretFile, mode: "json" }, + default: { + source: "file", + path: secretFile, + mode: "json", + ...(allowInsecureTempSecretFile ? { allowInsecurePath: true } : {}), + }, }, }, models: { @@ -658,7 +665,12 @@ describe("secrets runtime snapshot", () => { config: asConfig({ secrets: { providers: { - default: { source: "file", path: secretFile, mode: "json" }, + default: { + source: "file", + path: secretFile, + mode: "json", + ...(allowInsecureTempSecretFile ? { allowInsecurePath: true } : {}), + }, }, }, models: { From 69cd376e3b595d65d6706d66cef7d813089ac152 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Sat, 7 Mar 2026 14:32:59 -0800 Subject: [PATCH 216/311] fix(daemon): enable LaunchAgent before bootstrap on restart restartLaunchAgent was missing the launchctl enable call that installLaunchAgent already performs. launchd can persist a "disabled" state after bootout, causing bootstrap to silently fail and leaving the gateway unloaded until a manual reinstall. Fixes #39211 Co-Authored-By: Claude Opus 4.6 --- src/daemon/launchd.test.ts | 14 ++++++++++---- src/daemon/launchd.ts | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3030b6ffc4a88..4cd1f09afeb22 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -241,7 +241,7 @@ describe("launchd install", () => { expect(plist).toContain(`${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}`); }); - it("restarts LaunchAgent with bootout-bootstrap-kickstart order", async () => { + it("restarts LaunchAgent with bootout-enable-bootstrap-kickstart order", async () => { const env = createDefaultLaunchdEnv(); await restartLaunchAgent({ env, @@ -251,20 +251,26 @@ describe("launchd install", () => { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); + const serviceId = `${domain}/${label}`; const bootoutIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "bootout" && c[1] === `${domain}/${label}`, + (c) => c[0] === "bootout" && c[1] === serviceId, + ); + const enableIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "enable" && c[1] === serviceId, ); const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); const kickstartIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === `${domain}/${label}`, + (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, ); expect(bootoutIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(kickstartIndex).toBeGreaterThanOrEqual(0); - expect(bootoutIndex).toBeLessThan(bootstrapIndex); + expect(bootoutIndex).toBeLessThan(enableIndex); + expect(enableIndex).toBeLessThan(bootstrapIndex); expect(bootstrapIndex).toBeLessThan(kickstartIndex); }); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 5b62fad9805c2..b017a14a49501 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -466,6 +466,9 @@ export async function restartLaunchAgent({ await waitForPidExit(previousPid); } + // launchd can persist "disabled" state after bootout; clear it before bootstrap + // (matches the same guard in installLaunchAgent). + await execLaunchctl(["enable", `${domain}/${label}`]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); if (boot.code !== 0) { const detail = (boot.stderr || boot.stdout).trim(); From 44beb7be1fe7ef2553e48b2f160eb9546afeefcf Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Sun, 8 Mar 2026 15:01:32 -0700 Subject: [PATCH 217/311] fix(daemon): also enable LaunchAgent in repairLaunchAgentBootstrap The repair/recovery path had the same missing `enable` guard as `restartLaunchAgent`. If launchd persists a "disabled" state after a previous `bootout`, the `bootstrap` call in `repairLaunchAgentBootstrap` fails silently, leaving the gateway unloaded in the recovery flow. Add the same `enable` guard before `bootstrap` that was already applied to `installLaunchAgent` and (in this PR) `restartLaunchAgent`. Co-Authored-By: Claude Opus 4.6 --- src/daemon/launchd.test.ts | 20 +++++++++++++++++--- src/daemon/launchd.ts | 3 +++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 4cd1f09afeb22..3ebf2a22aed6b 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -156,7 +156,7 @@ describe("launchctl list detection", () => { }); describe("launchd bootstrap repair", () => { - it("bootstraps and kickstarts the resolved label", async () => { + it("enables, bootstraps, and kickstarts the resolved label", async () => { const env: Record = { HOME: "/Users/test", OPENCLAW_PROFILE: "default", @@ -167,9 +167,23 @@ describe("launchd bootstrap repair", () => { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; const plistPath = resolveLaunchAgentPlistPath(env); + const serviceId = `${domain}/${label}`; + + const enableIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "enable" && c[1] === serviceId, + ); + const bootstrapIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, + ); + const kickstartIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, + ); - expect(state.launchctlCalls).toContainEqual(["bootstrap", domain, plistPath]); - expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]); + expect(enableIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(kickstartIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeLessThan(bootstrapIndex); + expect(bootstrapIndex).toBeLessThan(kickstartIndex); }); }); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index b017a14a49501..dccea5780edd4 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -207,6 +207,9 @@ export async function repairLaunchAgentBootstrap(args: { const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); const plistPath = resolveLaunchAgentPlistPath(env); + // launchd can persist "disabled" state after bootout; clear it before bootstrap + // (matches the same guard in installLaunchAgent and restartLaunchAgent). + await execLaunchctl(["enable", `${domain}/${label}`]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); if (boot.code !== 0) { return { ok: false, detail: (boot.stderr || boot.stdout).trim() || undefined }; From 1d6a2d01656eee2bacf102988e177935b4884984 Mon Sep 17 00:00:00 2001 From: Daniel dos Santos Reis Date: Mon, 9 Mar 2026 01:16:21 +0100 Subject: [PATCH 218/311] fix(gateway): exit non-zero on restart shutdown timeout When a config-change restart hits the force-exit timeout, exit with code 1 instead of 0 so launchd/systemd treats it as a failure and triggers a clean process restart. Stop-timeout stays at exit(0) since graceful stops should not cause supervisor recovery. Closes #36822 --- src/cli/gateway-cli/run-loop.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index c6b7d5ea21e78..684e0a65c166d 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -106,7 +106,10 @@ export async function runGatewayLoop(params: { const forceExitMs = isRestart ? DRAIN_TIMEOUT_MS + SHUTDOWN_TIMEOUT_MS : SHUTDOWN_TIMEOUT_MS; const forceExitTimer = setTimeout(() => { gatewayLog.error("shutdown timed out; exiting without full cleanup"); - exitProcess(0); + // Exit non-zero on restart timeout so launchd/systemd treats it as a + // failure and triggers a clean process restart instead of assuming the + // shutdown was intentional. Stop-timeout stays at 0 (graceful). (#36822) + exitProcess(isRestart ? 1 : 0); }, forceExitMs); void (async () => { From 4bb8104810fd515480f7c162b0b7bebe72f3ac01 Mon Sep 17 00:00:00 2001 From: DevMac Date: Mon, 9 Mar 2026 02:20:12 +0100 Subject: [PATCH 219/311] test(secrets): skip ACL-dependent runtime snapshot tests on windows --- src/secrets/runtime.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 1d395ee44841c..463914bf8992f 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -532,6 +532,9 @@ describe("secrets runtime snapshot", () => { }); it("keeps active secrets runtime snapshots resolved after config writes", async () => { + if (os.platform() === "win32") { + return; + } await withTempHome("openclaw-secrets-runtime-write-", async (home) => { const configDir = path.join(home, ".openclaw"); const secretFile = path.join(configDir, "secrets.json"); @@ -613,6 +616,9 @@ describe("secrets runtime snapshot", () => { }); it("clears active secrets runtime state and throws when refresh fails after a write", async () => { + if (os.platform() === "win32") { + return; + } await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => { const configDir = path.join(home, ".openclaw"); const secretFile = path.join(configDir, "secrets.json"); From 31402b8542a9ae3b6114dfcc35bd92a91bdf0abf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:38:03 +0000 Subject: [PATCH 220/311] fix: add changelog for restart timeout recovery (#40380) (thanks @dsantoreis) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60c95a0c3cf8..e4f2a80a15f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. - Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. - Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus. +- Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis. ## 2026.3.7 From 93775ef6a484dfc3e55be2a57f7aba0cf829ede6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:40:59 +0000 Subject: [PATCH 221/311] fix(browser): enforce redirect-hop SSRF checks --- CHANGELOG.md | 6 ++ src/browser/navigation-guard.test.ts | 56 +++++++++++++++++++ src/browser/navigation-guard.ts | 30 ++++++++++ ...ssion.create-page.navigation-guard.test.ts | 28 +++++++++- src/browser/pw-session.ts | 8 ++- ...tools-core.snapshot.navigate-guard.test.ts | 29 ++++++++++ src/browser/pw-tools-core.snapshot.ts | 11 +++- ...er-context.remote-profile-tab-ops.suite.ts | 12 ++++ src/browser/server-context.tab-ops.ts | 8 +++ 9 files changed, 184 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f2a80a15f4d..e0c949eda2b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Fixes + +- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. + ## 2026.3.8 ### Changes diff --git a/src/browser/navigation-guard.test.ts b/src/browser/navigation-guard.test.ts index 8a8350cdb62c6..af6e7fba4348b 100644 --- a/src/browser/navigation-guard.test.ts +++ b/src/browser/navigation-guard.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js"; import { assertBrowserNavigationAllowed, + assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, InvalidBrowserNavigationUrlError, + requiresInspectableBrowserNavigationRedirects, } from "./navigation-guard.js"; function createLookupFn(address: string): LookupFn { @@ -147,4 +149,58 @@ describe("browser navigation guard", () => { }), ).resolves.toBeUndefined(); }); + + it("blocks private intermediate redirect hops", async () => { + const publicLookup = createLookupFn("93.184.216.34"); + const privateLookup = createLookupFn("127.0.0.1"); + const finalRequest = { + url: () => "https://public.example/final", + redirectedFrom: () => ({ + url: () => "http://private.example/internal", + redirectedFrom: () => ({ + url: () => "https://public.example/start", + redirectedFrom: () => null, + }), + }), + }; + + await expect( + assertBrowserNavigationRedirectChainAllowed({ + request: finalRequest, + lookupFn: vi.fn(async (hostname: string) => + hostname === "private.example" + ? privateLookup(hostname, { all: true }) + : publicLookup(hostname, { all: true }), + ) as unknown as LookupFn, + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + }); + + it("allows redirect chains when every hop is public", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + const finalRequest = { + url: () => "https://public.example/final", + redirectedFrom: () => ({ + url: () => "https://public.example/middle", + redirectedFrom: () => ({ + url: () => "https://public.example/start", + redirectedFrom: () => null, + }), + }), + }; + + await expect( + assertBrowserNavigationRedirectChainAllowed({ + request: finalRequest, + lookupFn, + }), + ).resolves.toBeUndefined(); + }); + + it("treats default browser SSRF mode as requiring redirect-hop inspection", () => { + expect(requiresInspectableBrowserNavigationRedirects()).toBe(true); + expect(requiresInspectableBrowserNavigationRedirects({ allowPrivateNetwork: true })).toBe( + false, + ); + }); }); diff --git a/src/browser/navigation-guard.ts b/src/browser/navigation-guard.ts index 496dee1946937..216140aba9832 100644 --- a/src/browser/navigation-guard.ts +++ b/src/browser/navigation-guard.ts @@ -25,12 +25,21 @@ export type BrowserNavigationPolicyOptions = { ssrfPolicy?: SsrFPolicy; }; +export type BrowserNavigationRequestLike = { + url(): string; + redirectedFrom(): BrowserNavigationRequestLike | null; +}; + export function withBrowserNavigationPolicy( ssrfPolicy?: SsrFPolicy, ): BrowserNavigationPolicyOptions { return ssrfPolicy ? { ssrfPolicy } : {}; } +export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean { + return !isPrivateNetworkAllowedByPolicy(ssrfPolicy); +} + export async function assertBrowserNavigationAllowed( opts: { url: string; @@ -102,3 +111,24 @@ export async function assertBrowserNavigationResultAllowed( await assertBrowserNavigationAllowed(opts); } } + +export async function assertBrowserNavigationRedirectChainAllowed( + opts: { + request?: BrowserNavigationRequestLike | null; + lookupFn?: LookupFn; + } & BrowserNavigationPolicyOptions, +): Promise { + const chain: string[] = []; + let current = opts.request ?? null; + while (current) { + chain.push(current.url()); + current = current.redirectedFrom(); + } + for (const url of chain.toReversed()) { + await assertBrowserNavigationAllowed({ + url, + lookupFn: opts.lookupFn, + ssrfPolicy: opts.ssrfPolicy, + }); + } +} diff --git a/src/browser/pw-session.create-page.navigation-guard.test.ts b/src/browser/pw-session.create-page.navigation-guard.test.ts index 95a0927300199..ae20e43c230a2 100644 --- a/src/browser/pw-session.create-page.navigation-guard.test.ts +++ b/src/browser/pw-session.create-page.navigation-guard.test.ts @@ -1,5 +1,6 @@ import { chromium } from "playwright-core"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { SsrFBlockedError } from "../infra/net/ssrf.js"; import * as chromeModule from "./chrome.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js"; @@ -9,7 +10,9 @@ const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl") function installBrowserMocks() { const pageOn = vi.fn(); - const pageGoto = vi.fn(async () => {}); + const pageGoto = vi.fn< + (...args: unknown[]) => Promise Record }> + >(async () => null); const pageTitle = vi.fn(async () => ""); const pageUrl = vi.fn(() => "about:blank"); const contextOn = vi.fn(); @@ -84,4 +87,27 @@ describe("pw-session createPageViaPlaywright navigation guard", () => { expect(created.targetId).toBe("TARGET_1"); expect(pageGoto).not.toHaveBeenCalled(); }); + + it("blocks private intermediate redirect hops", async () => { + const { pageGoto } = installBrowserMocks(); + pageGoto.mockResolvedValueOnce({ + request: () => ({ + url: () => "https://93.184.216.34/final", + redirectedFrom: () => ({ + url: () => "http://127.0.0.1:18080/internal-hop", + redirectedFrom: () => ({ + url: () => "https://93.184.216.34/start", + redirectedFrom: () => null, + }), + }), + }), + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + }); }); diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index a058ba07791b4..a7103c1174c09 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -22,6 +22,7 @@ import { getChromeWebSocketUrl } from "./chrome.js"; import { BrowserTabNotFoundError } from "./errors.js"; import { assertBrowserNavigationAllowed, + assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; @@ -787,8 +788,13 @@ export async function createPageViaPlaywright(opts: { url: targetUrl, ...navigationPolicy, }); - await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { + const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { // Navigation might fail for some URLs, but page is still created + return null; + }); + await assertBrowserNavigationRedirectChainAllowed({ + request: response?.request(), + ...navigationPolicy, }); await assertBrowserNavigationResultAllowed({ url: page.url(), diff --git a/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts b/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts index ef54087eb385f..993bbfcc3b16d 100644 --- a/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts +++ b/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { getPwToolsCoreSessionMocks, @@ -75,4 +76,32 @@ describe("pw-tools-core.snapshot navigate guard", () => { expect(goto).toHaveBeenCalledTimes(2); expect(result.url).toBe("https://example.com/recovered"); }); + + it("blocks private intermediate redirect hops during navigation", async () => { + const goto = vi.fn(async () => ({ + request: () => ({ + url: () => "https://93.184.216.34/final", + redirectedFrom: () => ({ + url: () => "http://127.0.0.1:18080/internal-hop", + redirectedFrom: () => ({ + url: () => "https://93.184.216.34/start", + redirectedFrom: () => null, + }), + }), + }), + })); + setPwToolsCoreCurrentPage({ + goto, + url: vi.fn(() => "https://93.184.216.34/final"), + }); + + await expect( + mod.navigateViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + expect(goto).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts index b3dc8dec7b050..09926626db183 100644 --- a/src/browser/pw-tools-core.snapshot.ts +++ b/src/browser/pw-tools-core.snapshot.ts @@ -2,6 +2,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; import { assertBrowserNavigationAllowed, + assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, withBrowserNavigationPolicy, } from "./navigation-guard.js"; @@ -196,8 +197,10 @@ export async function navigateViaPlaywright(opts: { const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)); let page = await getPageForTargetId(opts); ensurePageState(page); + const navigate = async () => await page.goto(url, { timeout }); + let response; try { - await page.goto(url, { timeout }); + response = await navigate(); } catch (err) { if (!isRetryableNavigateError(err)) { throw err; @@ -211,8 +214,12 @@ export async function navigateViaPlaywright(opts: { }).catch(() => {}); page = await getPageForTargetId(opts); ensurePageState(page); - await page.goto(url, { timeout }); + response = await navigate(); } + await assertBrowserNavigationRedirectChainAllowed({ + request: response?.request(), + ...withBrowserNavigationPolicy(opts.ssrfPolicy), + }); const finalUrl = page.url(); await assertBrowserNavigationResultAllowed({ url: finalUrl, diff --git a/src/browser/server-context.remote-profile-tab-ops.suite.ts b/src/browser/server-context.remote-profile-tab-ops.suite.ts index e0bd5815199f5..a2020f559e5e3 100644 --- a/src/browser/server-context.remote-profile-tab-ops.suite.ts +++ b/src/browser/server-context.remote-profile-tab-ops.suite.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import "./server-context.chrome-test-harness.js"; import * as chromeModule from "./chrome.js"; +import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import * as pwAiModule from "./pw-ai-module.js"; import { createBrowserRouteContext } from "./server-context.js"; import { @@ -230,6 +231,17 @@ describe("browser server-context remote profile tab operations", () => { expect(tabs.map((t) => t.targetId)).toEqual(["T1"]); }); + it("fails closed for remote tab opens in strict mode without Playwright", async () => { + vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null); + const { state, remote, fetchMock } = createRemoteRouteHarness(); + state.resolved.ssrfPolicy = {}; + + await expect(remote.openTab("https://example.com")).rejects.toBeInstanceOf( + InvalidBrowserNavigationUrlError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("does not enforce managed tab cap for remote openclaw profiles", async () => { const listPagesViaPlaywright = vi .fn() diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index 5adbd45923e88..24985430bdc4d 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -5,6 +5,8 @@ import type { ResolvedBrowserProfile } from "./config.js"; import { assertBrowserNavigationAllowed, assertBrowserNavigationResultAllowed, + InvalidBrowserNavigationUrlError, + requiresInspectableBrowserNavigationRedirects, withBrowserNavigationPolicy, } from "./navigation-guard.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; @@ -153,6 +155,12 @@ export function createProfileTabOps({ } } + if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) { + throw new InvalidBrowserNavigationUrlError( + "Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection", + ); + } + const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, From 41e023a80b602195690c4b1a683436f1761d912f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:48:52 +0000 Subject: [PATCH 222/311] fix(cron): restore owner-only tools for isolated runs --- CHANGELOG.md | 1 + .../isolated-agent/run.owner-auth.test.ts | 59 +++++++++++++++++++ src/cron/isolated-agent/run.ts | 3 + 3 files changed, 63 insertions(+) create mode 100644 src/cron/isolated-agent/run.owner-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c949eda2b41..510a403865b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. - Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus. - Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis. +- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference. ## 2026.3.7 diff --git a/src/cron/isolated-agent/run.owner-auth.test.ts b/src/cron/isolated-agent/run.owner-auth.test.ts new file mode 100644 index 0000000000000..f53a660031d7a --- /dev/null +++ b/src/cron/isolated-agent/run.owner-auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + clearFastTestEnv, + loadRunCronIsolatedAgentTurn, + resetRunCronIsolatedAgentTurnHarness, + resolveDeliveryTargetMock, + restoreFastTestEnv, + runEmbeddedPiAgentMock, + runWithModelFallbackMock, +} from "./run.test-harness.js"; + +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); + +function makeParams() { + return { + cfg: {}, + deps: {} as never, + job: { + id: "owner-auth", + name: "Owner Auth", + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + payload: { kind: "agentTurn", message: "check owner tools" }, + delivery: { mode: "none" }, + } as never, + message: "check owner tools", + sessionKey: "cron:owner-auth", + }; +} + +describe("runCronIsolatedAgentTurn owner auth", () => { + let previousFastTestEnv: string | undefined; + + beforeEach(() => { + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); + resolveDeliveryTargetMock.mockResolvedValue({ + channel: "telegram", + to: "123", + accountId: undefined, + error: undefined, + }); + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + const result = await run(provider, model); + return { result, provider, model, attempts: [] }; + }); + }); + + afterEach(() => { + restoreFastTestEnv(previousFastTestEnv); + }); + + it("passes senderIsOwner=true to isolated cron agent runs", async () => { + await runCronIsolatedAgentTurn(makeParams()); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.senderIsOwner).toBe(true); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 45ff9f9386bbb..813b99c0553bd 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -588,6 +588,9 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: agentSessionKey, agentId, trigger: "cron", + // Cron jobs are trusted local automation, so isolated runs should + // inherit owner-only tooling like local `openclaw agent` runs. + senderIsOwner: true, messageChannel, agentAccountId: resolvedDelivery.accountId, sessionFile, From 03a6e3b460a722c13aa843f1fe6c35f47064c764 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:52:04 +0000 Subject: [PATCH 223/311] test(cron): cover owner-only tool availability --- src/cron/isolated-agent/run.owner-auth.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cron/isolated-agent/run.owner-auth.test.ts b/src/cron/isolated-agent/run.owner-auth.test.ts index f53a660031d7a..92217326c5689 100644 --- a/src/cron/isolated-agent/run.owner-auth.test.ts +++ b/src/cron/isolated-agent/run.owner-auth.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "../../agents/test-helpers/fast-coding-tools.js"; +import { createOpenClawCodingTools } from "../../agents/pi-tools.js"; import { clearFastTestEnv, loadRunCronIsolatedAgentTurn, @@ -54,6 +56,11 @@ describe("runCronIsolatedAgentTurn owner auth", () => { await runCronIsolatedAgentTurn(makeParams()); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.senderIsOwner).toBe(true); + const senderIsOwner = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.senderIsOwner; + expect(senderIsOwner).toBe(true); + + const toolNames = createOpenClawCodingTools({ senderIsOwner }).map((tool) => tool.name); + expect(toolNames).toContain("cron"); + expect(toolNames).toContain("gateway"); }); }); From 88aee9161e0e6d32e810a25711e32a808a1777b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:51:57 +0000 Subject: [PATCH 224/311] fix(msteams): enforce sender allowlists with route allowlists --- CHANGELOG.md | 1 + .../message-handler.authz.test.ts | 78 ++++++++++++++++--- .../src/monitor-handler/message-handler.ts | 5 +- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 510a403865b87..92032a91680d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. +- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. ## 2026.3.8 diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index f019287e151ba..4997b43c75442 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -5,7 +5,7 @@ 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 () => { + function createDeps(cfg: OpenClawConfig) { const readAllowFromStore = vi.fn(async () => ["attacker-aad"]); setMSTeamsRuntime({ logging: { shouldLogVerbose: () => false }, @@ -35,16 +35,7 @@ describe("msteams monitor handler authz", () => { }; const deps: MSTeamsMessageHandlerDeps = { - cfg: { - channels: { - msteams: { - dmPolicy: "pairing", - allowFrom: [], - groupPolicy: "allowlist", - groupAllowFrom: [], - }, - }, - } as OpenClawConfig, + cfg, runtime: { error: vi.fn() } as unknown as RuntimeEnv, appId: "test-app", adapter: {} as MSTeamsMessageHandlerDeps["adapter"], @@ -65,6 +56,21 @@ describe("msteams monitor handler authz", () => { } as unknown as MSTeamsMessageHandlerDeps["log"], }; + return { conversationStore, deps, readAllowFromStore }; + } + + it("does not treat DM pairing-store entries as group allowlist entries", async () => { + const { conversationStore, deps, readAllowFromStore } = createDeps({ + channels: { + msteams: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + }, + }, + } as OpenClawConfig); + const handler = createMSTeamsMessageHandler(deps); await handler({ activity: { @@ -96,4 +102,54 @@ describe("msteams monitor handler authz", () => { }); expect(conversationStore.upsert).not.toHaveBeenCalled(); }); + + it("does not widen sender auth when only a teams route allowlist is configured", async () => { + const { conversationStore, deps } = createDeps({ + channels: { + msteams: { + dmPolicy: "pairing", + allowFrom: [], + groupPolicy: "allowlist", + groupAllowFrom: [], + teams: { + team123: { + channels: { + "19:group@thread.tacv2": { requireMention: false }, + }, + }, + }, + }, + }, + } as OpenClawConfig); + + const handler = createMSTeamsMessageHandler(deps); + await handler({ + activity: { + id: "msg-1", + type: "message", + text: "hello", + from: { + id: "attacker-id", + aadObjectId: "attacker-aad", + name: "Attacker", + }, + recipient: { + id: "bot-id", + name: "Bot", + }, + conversation: { + id: "19:group@thread.tacv2", + conversationType: "groupChat", + }, + channelData: { + team: { id: "team123", name: "Team 123" }, + channel: { name: "General" }, + }, + attachments: [], + }, + sendActivity: vi.fn(async () => undefined), + } as unknown as Parameters[0]); + + 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 2f14945f65e1f..6fe227537d30f 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -242,10 +242,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { } const senderGroupAccess = evaluateSenderGroupAccessForPolicy({ groupPolicy, - groupAllowFrom: - effectiveGroupAllowFrom.length > 0 || !channelGate.allowlistConfigured - ? effectiveGroupAllowFrom - : ["*"], + groupAllowFrom: effectiveGroupAllowFrom, senderId, isSenderAllowed: (_senderId, allowFrom) => resolveMSTeamsAllowlistMatch({ From eea925b12b0f4f0a8dfa9a3680daf251bbca0c7c Mon Sep 17 00:00:00 2001 From: merlin Date: Thu, 5 Mar 2026 18:17:58 +0800 Subject: [PATCH 225/311] fix(gateway): validate config before restart to prevent crash + macOS permission loss (#35862) When 'openclaw gateway restart' is run with an invalid config, the new process crashes on startup due to config validation failure. On macOS, this causes Full Disk Access (TCC) permissions to be lost because the respawned process has a different PID. Add getConfigValidationError() helper and pre-flight config validation in both runServiceRestart() and runServiceStart(). If config is invalid, abort with a clear error message instead of crashing. The config watcher's hot-reload path already had this guard (handleInvalidSnapshot), but the CLI restart/start commands did not. AI-assisted (OpenClaw agent, fully tested) --- .../lifecycle-core.config-guard.test.ts | 145 ++++++++++++++++++ src/cli/daemon-cli/lifecycle-core.ts | 44 +++++- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 src/cli/daemon-cli/lifecycle-core.config-guard.test.ts diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts new file mode 100644 index 0000000000000..4c1f1a5353713 --- /dev/null +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -0,0 +1,145 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const readConfigFileSnapshotMock = vi.fn(); +const loadConfig = vi.fn(() => ({})); + +const runtimeLogs: string[] = []; +const defaultRuntime = { + log: (message: string) => runtimeLogs.push(message), + error: vi.fn(), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +}; + +const service = { + label: "TestService", + loadedText: "loaded", + notLoadedText: "not loaded", + install: vi.fn(), + uninstall: vi.fn(), + stop: vi.fn(), + isLoaded: vi.fn(), + readCommand: vi.fn(), + readRuntime: vi.fn(), + restart: vi.fn(), +}; + +vi.mock("../../config/config.js", () => ({ + loadConfig: () => loadConfig(), + readConfigFileSnapshot: () => readConfigFileSnapshotMock(), +})); + +vi.mock("../../config/issue-format.js", () => ({ + formatConfigIssueLines: ( + issues: Array<{ path: string; message: string }>, + _prefix: string, + _opts?: unknown, + ) => issues.map((i) => `${i.path}: ${i.message}`), +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime, +})); + +describe("runServiceRestart config pre-flight (#35862)", () => { + let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart; + + beforeAll(async () => { + ({ runServiceRestart } = await import("./lifecycle-core.js")); + }); + + beforeEach(() => { + runtimeLogs.length = 0; + readConfigFileSnapshotMock.mockReset(); + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + issues: [], + }); + loadConfig.mockReset(); + loadConfig.mockReturnValue({}); + service.isLoaded.mockClear(); + service.readCommand.mockClear(); + service.restart.mockClear(); + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ environment: {} }); + service.restart.mockResolvedValue(undefined); + vi.unstubAllEnvs(); + vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); + vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", ""); + }); + + it("aborts restart when config is invalid", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: false, + config: {}, + issues: [{ path: "agents.defaults.pdfModel", message: "Unrecognized key" }], + }); + + await expect( + runServiceRestart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }), + ).rejects.toThrow("__exit__:1"); + + expect(service.restart).not.toHaveBeenCalled(); + }); + + it("proceeds with restart when config is valid", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + issues: [], + }); + + const result = await runServiceRestart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + expect(result).toBe(true); + expect(service.restart).toHaveBeenCalledTimes(1); + }); + + it("proceeds with restart when config file does not exist", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + + const result = await runServiceRestart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + expect(result).toBe(true); + expect(service.restart).toHaveBeenCalledTimes(1); + }); + + it("proceeds with restart when snapshot read throws", async () => { + readConfigFileSnapshotMock.mockRejectedValue(new Error("read failed")); + + const result = await runServiceRestart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + expect(result).toBe(true); + expect(service.restart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 00d70f24a8aed..012bcbac0c39f 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -1,5 +1,6 @@ import type { Writable } from "node:stream"; -import { readBestEffortConfig } from "../../config/config.js"; +import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/config.js"; +import { formatConfigIssueLines } from "../../config/issue-format.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { checkTokenDrift } from "../../daemon/service-audit.js"; import type { GatewayService } from "../../daemon/service.js"; @@ -107,6 +108,25 @@ async function resolveServiceLoadedOrFail(params: { } } +/** + * Best-effort config validation. Returns a string describing the issues if + * config exists and is invalid, or null if config is valid/missing/unreadable. + * (#35862) + */ +async function getConfigValidationError(): Promise { + try { + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.exists || snapshot.valid) { + return null; + } + return snapshot.issues.length > 0 + ? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n") + : "Unknown validation issue."; + } catch { + return null; + } +} + export async function runServiceUninstall(params: { serviceNoun: string; service: GatewayService; @@ -187,6 +207,17 @@ export async function runServiceStart(params: { }); return; } + // Pre-flight config validation (#35862) + { + const configError = await getConfigValidationError(); + if (configError) { + fail( + `${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`, + ); + return false; + } + } + try { await params.service.restart({ env: process.env, stdout }); } catch (err) { @@ -353,6 +384,17 @@ export async function runServiceRestart(params: { } } + // Pre-flight config validation (#35862) + { + const configError = await getConfigValidationError(); + if (configError) { + fail( + `${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`, + ); + return false; + } + } + try { if (loaded) { await params.service.restart({ env: process.env, stdout }); From 6740cdf160a2b84f061de94c3c774a26e7de19e5 Mon Sep 17 00:00:00 2001 From: merlin Date: Thu, 5 Mar 2026 18:36:39 +0800 Subject: [PATCH 226/311] fix(gateway): catch startup failure in run loop to prevent process exit (#35862) When an in-process restart (SIGUSR1) triggers a config-triggered restart and the new config is invalid, params.start() throws and the while loop exits, killing the process. On macOS this loses TCC permissions. Wrap params.start() in try/catch: on failure, set server=null, log the error, and wait for the next SIGUSR1 instead of crashing. --- src/cli/gateway-cli/run-loop.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 684e0a65c166d..c076eac040f7d 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -193,7 +193,19 @@ export async function runGatewayLoop(params: { // eslint-disable-next-line no-constant-condition while (true) { onIteration(); - server = await params.start(); + try { + server = await params.start(); + } catch (err) { + // If startup fails (e.g., invalid config after a config-triggered + // restart), keep the process alive and wait for the next SIGUSR1 + // instead of crashing. A crash here would respawn a new process that + // loses macOS Full Disk Access (TCC permissions are PID-bound). (#35862) + server = null; + gatewayLog.error( + `gateway startup failed: ${err instanceof Error ? err.message : String(err)}. ` + + "Process will stay alive; fix the issue and restart.", + ); + } await new Promise((resolve) => { restartResolver = resolve; }); From 335223af3291cd7d84d2d5e5ff014361b9cb71c5 Mon Sep 17 00:00:00 2001 From: merlin Date: Fri, 6 Mar 2026 00:36:05 +0800 Subject: [PATCH 227/311] test: add runServiceStart config pre-flight tests (#35862) Address Greptile review: add test coverage for runServiceStart path. The error message copy-paste issue was already fixed in the DRY refactor (uses params.serviceNoun instead of hardcoded 'restart'). --- .../lifecycle-core.config-guard.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts index 4c1f1a5353713..a785cde4d9b1d 100644 --- a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -143,3 +143,64 @@ describe("runServiceRestart config pre-flight (#35862)", () => { expect(service.restart).toHaveBeenCalledTimes(1); }); }); + +describe("runServiceStart config pre-flight (#35862)", () => { + let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart; + + beforeAll(async () => { + ({ runServiceStart } = await import("./lifecycle-core.js")); + }); + + beforeEach(() => { + runtimeLogs.length = 0; + readConfigFileSnapshotMock.mockReset(); + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + issues: [], + }); + service.isLoaded.mockClear(); + service.restart.mockClear(); + service.isLoaded.mockResolvedValue(true); + service.restart.mockResolvedValue(undefined); + }); + + it("aborts start when config is invalid", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: false, + config: {}, + issues: [{ path: "agents.defaults.pdfModel", message: "Unrecognized key" }], + }); + + await expect( + runServiceStart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }), + ).rejects.toThrow("__exit__:1"); + + expect(service.restart).not.toHaveBeenCalled(); + }); + + it("proceeds with start when config is valid", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + issues: [], + }); + + await runServiceStart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + expect(service.restart).toHaveBeenCalledTimes(1); + }); +}); From c79a0dbdb4b4ee83712ee1e16bc575d7cdb0c8b2 Mon Sep 17 00:00:00 2001 From: merlin Date: Sun, 8 Mar 2026 00:02:49 +0800 Subject: [PATCH 228/311] fix: address bot review feedback on #35862 - Remove dead 'return false' in runServiceStart (Greptile) - Include stack trace in run-loop crash guard error log (Greptile) - Only catch startup errors on subsequent restarts, not initial start (Codex P1) - Add JSDoc note about env var false positive edge case (Codex P1) --- src/cli/daemon-cli/lifecycle-core.ts | 8 ++++++-- src/cli/gateway-cli/run-loop.ts | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 012bcbac0c39f..d99e129084bab 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -111,7 +111,11 @@ async function resolveServiceLoadedOrFail(params: { /** * Best-effort config validation. Returns a string describing the issues if * config exists and is invalid, or null if config is valid/missing/unreadable. - * (#35862) + * + * Note: This reads the config file snapshot in the current CLI environment. + * Configs using env vars only available in the service context (launchd/systemd) + * may produce false positives, but the check is intentionally best-effort — + * a false positive here is safer than a crash on startup. (#35862) */ async function getConfigValidationError(): Promise { try { @@ -214,7 +218,7 @@ export async function runServiceStart(params: { fail( `${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`, ); - return false; + return; } } diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index c076eac040f7d..f6fc2a1d091d8 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -190,20 +190,27 @@ export async function runGatewayLoop(params: { // Keep process alive; SIGUSR1 triggers an in-process restart (no supervisor required). // SIGTERM/SIGINT still exit after a graceful shutdown. + let isFirstStart = true; // eslint-disable-next-line no-constant-condition while (true) { onIteration(); try { server = await params.start(); + isFirstStart = false; } catch (err) { - // If startup fails (e.g., invalid config after a config-triggered - // restart), keep the process alive and wait for the next SIGUSR1 - // instead of crashing. A crash here would respawn a new process that - // loses macOS Full Disk Access (TCC permissions are PID-bound). (#35862) + // On initial startup, let the error propagate so the outer handler + // can report "Gateway failed to start" and exit non-zero. Only + // swallow errors on subsequent in-process restarts to keep the + // process alive (a crash would lose macOS TCC permissions). (#35862) + if (isFirstStart) { + throw err; + } server = null; + const errMsg = err instanceof Error ? err.message : String(err); + const errStack = err instanceof Error && err.stack ? `\n${err.stack}` : ""; gatewayLog.error( - `gateway startup failed: ${err instanceof Error ? err.message : String(err)}. ` + - "Process will stay alive; fix the issue and restart.", + `gateway startup failed: ${errMsg}. ` + + `Process will stay alive; fix the issue and restart.${errStack}`, ); } await new Promise((resolve) => { From f184e7811cf45f0526a05a6972524fce03b6534c Mon Sep 17 00:00:00 2001 From: merlin Date: Mon, 9 Mar 2026 08:25:31 +0800 Subject: [PATCH 229/311] fix: move config pre-flight before onNotLoaded in runServiceRestart (Codex P2) The config check was positioned after onNotLoaded, which could send SIGUSR1 to an unmanaged process before config was validated. --- src/cli/daemon-cli/lifecycle-core.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index d99e129084bab..75bba03b418f8 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -333,6 +333,19 @@ export async function runServiceRestart(params: { if (loaded === null) { return false; } + + // Pre-flight config validation: check before any restart action (including + // onNotLoaded which may send SIGUSR1 to an unmanaged process). (#35862) + { + const configError = await getConfigValidationError(); + if (configError) { + fail( + `${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`, + ); + return false; + } + } + if (!loaded) { try { handledNotLoaded = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null; @@ -388,17 +401,6 @@ export async function runServiceRestart(params: { } } - // Pre-flight config validation (#35862) - { - const configError = await getConfigValidationError(); - if (configError) { - fail( - `${params.serviceNoun} aborted: config is invalid.\n${configError}\nFix the config and retry, or run "openclaw doctor" to repair.`, - ); - return false; - } - } - try { if (loaded) { await params.service.restart({ env: process.env, stdout }); From f84adcbe8826343011ccb8814d570a1aafd0a027 Mon Sep 17 00:00:00 2001 From: merlin Date: Mon, 9 Mar 2026 12:36:05 +0800 Subject: [PATCH 230/311] fix: release gateway lock on restart failure + reply to Codex reviews - Release gateway lock when in-process restart fails, so daemon restart/stop can still manage the process (Codex P2) - P1 (env mismatch) already addressed: best-effort by design, documented in JSDoc --- src/cli/gateway-cli/run-loop.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index f6fc2a1d091d8..4fbb480726445 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -206,6 +206,11 @@ export async function runGatewayLoop(params: { throw err; } server = null; + // Release the gateway lock so that `daemon restart/stop` (which + // discovers PIDs via the gateway port) can still manage the process. + // Without this, the process holds the lock but is not listening, + // forcing manual cleanup. (#35862) + await releaseLockIfHeld(); const errMsg = err instanceof Error ? err.message : String(err); const errStack = err instanceof Error && err.stack ? `\n${err.stack}` : ""; gatewayLog.error( From cf796e2a223f90902fcd122e3c797acf20990255 Mon Sep 17 00:00:00 2001 From: dimatu Date: Thu, 19 Feb 2026 02:54:18 +0000 Subject: [PATCH 231/311] fix(gateway): detect launchd supervision via XPC_SERVICE_NAME On macOS, launchd sets XPC_SERVICE_NAME on managed processes but does not set LAUNCH_JOB_LABEL or LAUNCH_JOB_NAME. Without checking XPC_SERVICE_NAME, isLikelySupervisedProcess() returns false for launchd-managed gateways, causing restartGatewayProcessWithFreshPid() to fork a detached child instead of returning "supervised". The detached child holds the gateway lock while launchd simultaneously respawns the original process (KeepAlive=true), leading to an infinite lock-timeout / restart loop. Co-Authored-By: Claude Opus 4.6 --- src/infra/process-respawn.test.ts | 10 ++++++++++ src/infra/supervisor-markers.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/src/infra/process-respawn.test.ts b/src/infra/process-respawn.test.ts index 62091423af73e..7b9a9df125279 100644 --- a/src/infra/process-respawn.test.ts +++ b/src/infra/process-respawn.test.ts @@ -108,6 +108,16 @@ describe("restartGatewayProcessWithFreshPid", () => { expect(spawnMock).not.toHaveBeenCalled(); }); + it("returns supervised when XPC_SERVICE_NAME is set by launchd", () => { + clearSupervisorHints(); + setPlatform("darwin"); + process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway"; + const result = restartGatewayProcessWithFreshPid(); + expect(result.mode).toBe("supervised"); + expect(triggerOpenClawRestartMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + it("spawns detached child with current exec argv", () => { delete process.env.OPENCLAW_NO_RESPAWN; clearSupervisorHints(); diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index f024ddeca2ede..5b714735724a9 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -1,6 +1,7 @@ const LAUNCHD_SUPERVISOR_HINT_ENV_VARS = [ "LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", + "XPC_SERVICE_NAME", "OPENCLAW_LAUNCHD_LABEL", ] as const; From fd902b065148cf5ac6eab24fdd5b68377424d692 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:56:33 +0000 Subject: [PATCH 232/311] fix: detect launchd supervision via xpc service name (#20555) (thanks @dimat) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92032a91680d4..399c916ae09c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,8 @@ Docs: https://docs.openclaw.ai - Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. - Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus. - Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis. +- Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468. +- Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat. - Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference. ## 2026.3.7 From cf3a479bd1204f62eef7dd82b4aa328749ae6c91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:59:32 +0000 Subject: [PATCH 233/311] fix(node-host): bind bun and deno approval scripts --- CHANGELOG.md | 1 + src/node-host/invoke-system-run-plan.test.ts | 156 ++++++++++++- src/node-host/invoke-system-run-plan.ts | 233 ++++++++++++++++++- src/node-host/invoke-system-run.test.ts | 131 +++++++++++ 4 files changed, 512 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399c916ae09c7..a58cab6915fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. - MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. +- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. ## 2026.3.8 diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 07b60c160fe19..8b10bceb2c074 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -24,27 +24,68 @@ type HardeningCase = { checkRawCommandMatchesArgv?: boolean; }; -function createScriptOperandFixture(tmp: string): { +type ScriptOperandFixture = { command: string[]; scriptPath: string; initialBody: string; -} { + expectedArgvIndex: number; +}; + +type RuntimeFixture = { + name: string; + argv: string[]; + scriptName: string; + initialBody: string; + expectedArgvIndex: number; + binName?: string; +}; + +function createScriptOperandFixture(tmp: string, fixture?: RuntimeFixture): ScriptOperandFixture { + if (fixture) { + return { + command: fixture.argv, + scriptPath: path.join(tmp, fixture.scriptName), + initialBody: fixture.initialBody, + expectedArgvIndex: fixture.expectedArgvIndex, + }; + } if (process.platform === "win32") { - const scriptPath = path.join(tmp, "run.js"); return { command: [process.execPath, "./run.js"], - scriptPath, + scriptPath: path.join(tmp, "run.js"), initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, }; } - const scriptPath = path.join(tmp, "run.sh"); return { command: ["/bin/sh", "./run.sh"], - scriptPath, + scriptPath: path.join(tmp, "run.sh"), initialBody: "#!/bin/sh\necho SAFE\n", + expectedArgvIndex: 1, }; } +function withFakeRuntimeBin(params: { binName: string; run: () => T }): T { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.binName}-bin-`)); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const runtimePath = path.join(binDir, params.binName); + fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.chmodSync(runtimePath, 0o755); + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + try { + return params.run(); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + describe("hardenApprovedExecutionPaths", () => { const cases: HardeningCase[] = [ { @@ -150,6 +191,63 @@ describe("hardenApprovedExecutionPaths", () => { }); } + const mutableOperandCases: RuntimeFixture[] = [ + { + name: "bun direct file", + binName: "bun", + argv: ["bun", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 1, + }, + { + name: "bun run file", + binName: "bun", + argv: ["bun", "run", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 2, + }, + { + name: "deno run file with flags", + binName: "deno", + argv: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 5, + }, + ]; + + for (const runtimeCase of mutableOperandCases) { + it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { + withFakeRuntimeBin({ + binName: runtimeCase.binName!, + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); + const fixture = createScriptOperandFixture(tmp, runtimeCase); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toEqual({ + argvIndex: fixture.expectedArgvIndex, + path: fs.realpathSync(fixture.scriptPath), + sha256: expect.any(String), + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + } + it("captures mutable shell script operands in approval plans", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-script-plan-")); const fixture = createScriptOperandFixture(tmp); @@ -167,7 +265,7 @@ describe("hardenApprovedExecutionPaths", () => { throw new Error("unreachable"); } expect(prepared.plan.mutableFileOperand).toEqual({ - argvIndex: 1, + argvIndex: fixture.expectedArgvIndex, path: fs.realpathSync(fixture.scriptPath), sha256: expect.any(String), }); @@ -175,4 +273,48 @@ describe("hardenApprovedExecutionPaths", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it("does not snapshot bun package script names", () => { + withFakeRuntimeBin({ + binName: "bun", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bun-package-script-")); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["bun", "run", "dev"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toBeUndefined(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("does not snapshot deno eval invocations", () => { + withFakeRuntimeBin({ + binName: "deno", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-deno-eval-")); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["deno", "eval", "console.log('SAFE')"], + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + expect(prepared.plan.mutableFileOperand).toBeUndefined(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); }); diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index c35bf74866790..111664713d9ce 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -32,6 +32,89 @@ const MUTABLE_ARGV1_INTERPRETER_PATTERNS = [ /^ruby$/, ] as const; +const BUN_SUBCOMMANDS = new Set([ + "add", + "audit", + "completions", + "create", + "exec", + "help", + "init", + "install", + "link", + "outdated", + "patch", + "pm", + "publish", + "remove", + "repl", + "run", + "test", + "unlink", + "update", + "upgrade", + "x", +]); + +const BUN_OPTIONS_WITH_VALUE = new Set([ + "--backend", + "--bunfig", + "--conditions", + "--config", + "--console-depth", + "--cwd", + "--define", + "--elide-lines", + "--env-file", + "--extension-order", + "--filter", + "--hot", + "--inspect", + "--inspect-brk", + "--inspect-wait", + "--install", + "--jsx-factory", + "--jsx-fragment", + "--jsx-import-source", + "--loader", + "--origin", + "--port", + "--preload", + "--smol", + "--tsconfig-override", + "-c", + "-e", + "-p", + "-r", +]); + +const DENO_RUN_OPTIONS_WITH_VALUE = new Set([ + "--cached-only", + "--cert", + "--config", + "--env-file", + "--ext", + "--harmony-import-attributes", + "--import-map", + "--inspect", + "--inspect-brk", + "--inspect-wait", + "--location", + "--log-level", + "--lock", + "--node-modules-dir", + "--no-check", + "--preload", + "--reload", + "--seed", + "--strace-ops", + "--unstable-bare-node-builtins", + "--v8-flags", + "--watch", + "--watch-exclude", + "-L", +]); + function normalizeString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -94,6 +177,28 @@ function hashFileContentsSync(filePath: string): string { return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } +function looksLikePathToken(token: string): boolean { + return ( + token.startsWith(".") || + token.startsWith("/") || + token.startsWith("\\") || + token.includes("/") || + token.includes("\\") || + path.extname(token).length > 0 + ); +} + +function resolvesToExistingFileSync(rawOperand: string, cwd: string | undefined): boolean { + if (!rawOperand) { + return false; + } + try { + return fs.statSync(path.resolve(cwd ?? process.cwd(), rawOperand)).isFile(); + } catch { + return false; + } +} + function unwrapArgvForMutableOperand(argv: string[]): { argv: string[]; baseIndex: number } { let current = argv; let baseIndex = 0; @@ -146,7 +251,117 @@ function resolvePosixShellScriptOperandIndex(argv: string[]): number | null { return null; } -function resolveMutableFileOperandIndex(argv: string[]): number | null { +function resolveOptionFilteredFileOperandIndex(params: { + argv: string[]; + startIndex: number; + cwd: string | undefined; + optionsWithValue?: ReadonlySet; +}): number | null { + let afterDoubleDash = false; + for (let i = params.startIndex; i < params.argv.length; i += 1) { + const token = params.argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return resolvesToExistingFileSync(token, params.cwd) ? i : null; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-") { + return null; + } + if (token.startsWith("-")) { + if (!token.includes("=") && params.optionsWithValue?.has(token)) { + i += 1; + } + continue; + } + return resolvesToExistingFileSync(token, params.cwd) ? i : null; + } + return null; +} + +function resolveOptionFilteredPositionalIndex(params: { + argv: string[]; + startIndex: number; + optionsWithValue?: ReadonlySet; +}): number | null { + let afterDoubleDash = false; + for (let i = params.startIndex; i < params.argv.length; i += 1) { + const token = params.argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return i; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-") { + return null; + } + if (token.startsWith("-")) { + if (!token.includes("=") && params.optionsWithValue?.has(token)) { + i += 1; + } + continue; + } + return i; + } + return null; +} + +function resolveBunScriptOperandIndex(params: { + argv: string[]; + cwd: string | undefined; +}): number | null { + const directIndex = resolveOptionFilteredPositionalIndex({ + argv: params.argv, + startIndex: 1, + optionsWithValue: BUN_OPTIONS_WITH_VALUE, + }); + if (directIndex === null) { + return null; + } + const directToken = params.argv[directIndex]?.trim() ?? ""; + if (directToken === "run") { + return resolveOptionFilteredFileOperandIndex({ + argv: params.argv, + startIndex: directIndex + 1, + cwd: params.cwd, + optionsWithValue: BUN_OPTIONS_WITH_VALUE, + }); + } + if (BUN_SUBCOMMANDS.has(directToken)) { + return null; + } + if (!looksLikePathToken(directToken)) { + return null; + } + return directIndex; +} + +function resolveDenoRunScriptOperandIndex(params: { + argv: string[]; + cwd: string | undefined; +}): number | null { + if ((params.argv[1]?.trim() ?? "") !== "run") { + return null; + } + return resolveOptionFilteredFileOperandIndex({ + argv: params.argv, + startIndex: 2, + cwd: params.cwd, + optionsWithValue: DENO_RUN_OPTIONS_WITH_VALUE, + }); +} + +function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined): number | null { const unwrapped = unwrapArgvForMutableOperand(argv); const executable = normalizeExecutableToken(unwrapped.argv[0] ?? ""); if (!executable) { @@ -157,6 +372,20 @@ function resolveMutableFileOperandIndex(argv: string[]): number | null { return shellIndex === null ? null : unwrapped.baseIndex + shellIndex; } if (!MUTABLE_ARGV1_INTERPRETER_PATTERNS.some((pattern) => pattern.test(executable))) { + if (executable === "bun") { + const bunIndex = resolveBunScriptOperandIndex({ + argv: unwrapped.argv, + cwd, + }); + return bunIndex === null ? null : unwrapped.baseIndex + bunIndex; + } + if (executable === "deno") { + const denoIndex = resolveDenoRunScriptOperandIndex({ + argv: unwrapped.argv, + cwd, + }); + return denoIndex === null ? null : unwrapped.baseIndex + denoIndex; + } return null; } const operand = unwrapped.argv[1]?.trim() ?? ""; @@ -170,7 +399,7 @@ function resolveMutableFileOperandSnapshotSync(params: { argv: string[]; cwd: string | undefined; }): { ok: true; snapshot: SystemRunApprovalFileOperand | null } | { ok: false; message: string } { - const argvIndex = resolveMutableFileOperandIndex(params.argv); + const argvIndex = resolveMutableFileOperandIndex(params.argv, params.cwd); if (argvIndex === null) { return { ok: true, snapshot: null }; } diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 9295460a23a24..2d78ec465009b 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -109,6 +109,29 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }; } + function createRuntimeScriptOperandFixture(params: { tmp: string; runtime: "bun" | "deno" }): { + command: string[]; + scriptPath: string; + initialBody: string; + changedBody: string; + } { + const scriptPath = path.join(params.tmp, "run.ts"); + if (params.runtime === "bun") { + return { + command: ["bun", "run", "./run.ts"], + scriptPath, + initialBody: 'console.log("SAFE");\n', + changedBody: 'console.log("PWNED");\n', + }; + } + return { + command: ["deno", "run", "-A", "--allow-read", "--", "./run.ts"], + scriptPath, + initialBody: 'console.log("SAFE");\n', + changedBody: 'console.log("PWNED");\n', + }; + } + function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; } @@ -199,6 +222,30 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } } + async function withFakeRuntimeOnPath(params: { + runtime: "bun" | "deno"; + run: () => Promise; + }): Promise { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.runtime}-path-`)); + const binDir = path.join(tmp, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const runtimePath = path.join(binDir, params.runtime); + fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.chmodSync(runtimePath, 0o755); + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + try { + return await params.run(); + } finally { + if (oldPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = oldPath; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + function expectCommandPinnedToCanonicalPath(params: { runCommand: MockedRunCommand; expected: string; @@ -788,6 +835,90 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); + for (const runtime of ["bun", "deno"] as const) { + it(`denies approval-based execution when a ${runtime} script operand changes after approval`, async () => { + await withFakeRuntimeOnPath({ + runtime, + run: async () => { + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-approval-${runtime}-script-drift-`), + ); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime }); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + fs.writeFileSync(fixture.scriptPath, fixture.changedBody); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", + exact: true, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it(`keeps approved ${runtime} script execution working when the script is unchanged`, async () => { + await withFakeRuntimeOnPath({ + runtime, + run: async () => { + const tmp = fs.mkdtempSync( + path.join(os.tmpdir(), `openclaw-approval-${runtime}-script-stable-`), + ); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime }); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); + } + + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.rawCommand, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + } + it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => { const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const runCommand = vi.fn(async () => { From 9abf014f3502009faf9c73df5ca2cff719e54639 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 05:59:31 +0000 Subject: [PATCH 234/311] fix(skills): pin validated download roots --- CHANGELOG.md | 1 + src/agents/skills-install-download.ts | 23 ++++++++---- src/agents/skills-install.download.test.ts | 41 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a58cab6915fe6..f1b4cbe4a93fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. - MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. - Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. +- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. ## 2026.3.8 diff --git a/src/agents/skills-install-download.ts b/src/agents/skills-install-download.ts index 345fd1a3698f8..f5c62ceb0e8cf 100644 --- a/src/agents/skills-install-download.ts +++ b/src/agents/skills-install-download.ts @@ -130,22 +130,33 @@ export async function installDownloadSpec(params: { filename = "download"; } + let canonicalSafeRoot = ""; let targetDir = ""; try { - targetDir = resolveDownloadTargetDir(entry, spec); - await ensureDir(targetDir); + await ensureDir(safeRoot); await assertCanonicalPathWithinBase({ baseDir: safeRoot, - candidatePath: targetDir, + candidatePath: safeRoot, boundaryLabel: "skill tools directory", }); + canonicalSafeRoot = await fs.promises.realpath(safeRoot); + + const requestedTargetDir = resolveDownloadTargetDir(entry, spec); + await ensureDir(requestedTargetDir); + await assertCanonicalPathWithinBase({ + baseDir: safeRoot, + candidatePath: requestedTargetDir, + boundaryLabel: "skill tools directory", + }); + const targetRelativePath = path.relative(safeRoot, requestedTargetDir); + targetDir = path.join(canonicalSafeRoot, targetRelativePath); } catch (err) { const message = err instanceof Error ? err.message : String(err); return { ok: false, message, stdout: "", stderr: message, code: null }; } const archivePath = path.join(targetDir, filename); - const archiveRelativePath = path.relative(safeRoot, archivePath); + const archiveRelativePath = path.relative(canonicalSafeRoot, archivePath); if ( !archiveRelativePath || archiveRelativePath === ".." || @@ -164,7 +175,7 @@ export async function installDownloadSpec(params: { try { const result = await downloadFile({ url, - rootDir: safeRoot, + rootDir: canonicalSafeRoot, relativePath: archiveRelativePath, timeoutMs, }); @@ -198,7 +209,7 @@ export async function installDownloadSpec(params: { try { await assertCanonicalPathWithinBase({ - baseDir: safeRoot, + baseDir: canonicalSafeRoot, candidatePath: targetDir, boundaryLabel: "skill tools directory", }); diff --git a/src/agents/skills-install.download.test.ts b/src/agents/skills-install.download.test.ts index e030b9cbf760c..0c357089678f0 100644 --- a/src/agents/skills-install.download.test.ts +++ b/src/agents/skills-install.download.test.ts @@ -251,6 +251,47 @@ describe("installDownloadSpec extraction safety", () => { ), ).toBe("hi"); }); + + it.runIf(process.platform !== "win32")( + "fails closed when the lexical tools root is rebound before the final copy", + async () => { + const entry = buildEntry("base-rebind"); + const safeRoot = resolveSkillToolsRootDir(entry); + const outsideRoot = path.join(workspaceDir, "outside-root"); + await fs.mkdir(outsideRoot, { recursive: true }); + + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue(new Uint8Array(Buffer.from("payload"))); + const reboundRoot = `${safeRoot}-rebound`; + await fs.rename(safeRoot, reboundRoot); + await fs.symlink(outsideRoot, safeRoot); + controller.close(); + }, + }), + { status: 200 }, + ), + release: async () => undefined, + }); + + const result = await installDownloadSpec({ + entry, + spec: { + kind: "download", + id: "dl", + url: "https://example.invalid/payload.bin", + extract: false, + targetDir: "runtime", + }, + timeoutMs: 30_000, + }); + + expect(result.ok).toBe(false); + expect(await fileExists(path.join(outsideRoot, "runtime", "payload.bin"))).toBe(false); + }, + ); }); describe("installDownloadSpec extraction safety (tar.bz2)", () => { From 2767907abf500f3eeca64f01075fc6a711a4a4c3 Mon Sep 17 00:00:00 2001 From: George Kalogirou Date: Mon, 23 Feb 2026 00:14:13 +0200 Subject: [PATCH 235/311] fix(telegram): abort in-flight getUpdates fetch on shutdown When the gateway receives SIGTERM, runner.stop() stops the grammY polling loop but does not abort the in-flight getUpdates HTTP request. That request hangs for up to 30 seconds (the Telegram API timeout). If a new gateway instance starts polling during that window, Telegram returns a 409 Conflict error, causing message loss and requiring exponential backoff recovery. This is especially problematic with service managers (launchd, systemd) that restart the process immediately after SIGTERM. Wire an AbortController into the fetch layer so every Telegram API request (especially the long-polling getUpdates) aborts immediately on shutdown: - bot.ts: Accept optional fetchAbortSignal in TelegramBotOptions; wrap the grammY fetch with AbortSignal.any() to merge the shutdown signal. - monitor.ts: Create a per-iteration AbortController, pass its signal to createTelegramBot, and abort it from the SIGTERM handler, force-restart path, and finally block. Co-Authored-By: Claude Opus 4.6 --- src/telegram/bot.create-telegram-bot.test.ts | 21 +++++++++ src/telegram/bot.ts | 47 ++++++++++++++++++- src/telegram/monitor.test.ts | 49 +++++++++++++++++++- src/telegram/monitor.ts | 25 ++++++++-- 4 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 378c1eb106514..07edc4f5432fe 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -75,6 +75,27 @@ describe("createTelegramBot", () => { globalThis.fetch = originalFetch; } }); + it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => { + const originalFetch = globalThis.fetch; + const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => init?.signal); + const shutdown = new AbortController(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + try { + createTelegramBot({ token: "tok", fetchAbortSignal: shutdown.signal }); + const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch as ((input: RequestInfo | URL, init?: RequestInit) => Promise); + expect(clientFetch).toBeTypeOf("function"); + + const observedSignal = (await clientFetch("https://example.test")) as AbortSignal; + expect(observedSignal).toBeInstanceOf(AbortSignal); + expect(observedSignal.aborted).toBe(false); + + shutdown.abort(new Error("shutdown")); + expect(observedSignal.aborted).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); it("applies global and per-account timeoutSeconds", () => { loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 9549fe71986f8..b7bb8c34e60ca 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -54,6 +54,8 @@ export type TelegramBotOptions = { replyToMode?: ReplyToMode; proxyFetch?: typeof fetch; config?: OpenClawConfig; + /** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */ + fetchAbortSignal?: AbortSignal; updateOffset?: { lastUpdateId?: number | null; onUpdateId?: (updateId: number) => void | Promise; @@ -103,14 +105,55 @@ export function createTelegramBot(opts: TelegramBotOptions) { // grammY's ApiClientOptions types still track `node-fetch` types; Node 22+ global fetch // (undici) is structurally compatible at runtime but not assignable in TS. const fetchForClient = fetchImpl as unknown as NonNullable; + + // When a shutdown abort signal is provided, wrap fetch so every Telegram API request + // (especially long-polling getUpdates) aborts immediately on shutdown. Without this, + // the in-flight getUpdates hangs for up to 30s, and a new gateway instance starting + // its own poll triggers a 409 Conflict from Telegram. + let finalFetch: NonNullable | undefined = + shouldProvideFetch && fetchImpl ? fetchForClient : undefined; + if (opts.fetchAbortSignal) { + const baseFetch = + finalFetch ?? (globalThis.fetch as unknown as NonNullable); + const shutdownSignal = opts.fetchAbortSignal; + // Cast baseFetch to global fetch to avoid node-fetch ↔ global-fetch type divergence; + // they are runtime-compatible (the codebase already casts at every fetch boundary). + const callFetch = baseFetch as unknown as typeof globalThis.fetch; + finalFetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const controller = new AbortController(); + const abortWith = (signal: AbortSignal) => controller.abort(signal.reason); + const onShutdown = () => abortWith(shutdownSignal); + let onRequestAbort: (() => void) | undefined; + if (shutdownSignal.aborted) { + abortWith(shutdownSignal); + } else { + shutdownSignal.addEventListener("abort", onShutdown, { once: true }); + } + if (init?.signal) { + if (init.signal.aborted) { + abortWith(init.signal); + } else { + onRequestAbort = () => abortWith(init.signal as AbortSignal); + init.signal.addEventListener("abort", onRequestAbort, { once: true }); + } + } + return callFetch(input, { ...init, signal: controller.signal }).finally(() => { + shutdownSignal.removeEventListener("abort", onShutdown); + if (init?.signal && onRequestAbort) { + init.signal.removeEventListener("abort", onRequestAbort); + } + }); + }) as unknown as NonNullable; + } + const timeoutSeconds = typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) : undefined; const client: ApiClientOptions | undefined = - shouldProvideFetch || timeoutSeconds + finalFetch || timeoutSeconds ? { - ...(shouldProvideFetch && fetchImpl ? { fetch: fetchForClient } : {}), + ...(finalFetch ? { fetch: finalFetch } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), } : undefined; diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index d5dc43c5335b9..bd9a35fc97cc4 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -63,6 +63,10 @@ const { createTelegramBotErrors } = vi.hoisted(() => ({ createTelegramBotErrors: [] as unknown[], })); +const { createTelegramBotCalls } = vi.hoisted(() => ({ + createTelegramBotCalls: [] as Array>, +})); + const { createdBotStops } = vi.hoisted(() => ({ createdBotStops: [] as Array void>>>, })); @@ -142,7 +146,8 @@ vi.mock("../config/config.js", async (importOriginal) => { }); vi.mock("./bot.js", () => ({ - createTelegramBot: () => { + createTelegramBot: (opts: Record) => { + createTelegramBotCalls.push(opts); const nextError = createTelegramBotErrors.shift(); if (nextError) { throw nextError; @@ -217,6 +222,7 @@ describe("monitorTelegramProvider (grammY)", () => { task: () => Promise.reject(new Error("runSpy called without explicit test stub")), }), ); + createTelegramBotCalls.length = 0; computeBackoff.mockClear(); sleepWithAbort.mockClear(); startTelegramWebhookSpy.mockClear(); @@ -442,6 +448,47 @@ describe("monitorTelegramProvider (grammY)", () => { expect(runSpy).toHaveBeenCalledTimes(2); }); + it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => { + const abort = new AbortController(); + let running = true; + let releaseTask: (() => void) | undefined; + const stop = vi.fn(async () => { + running = false; + releaseTask?.(); + }); + + runSpy + .mockImplementationOnce(() => + makeRunnerStub({ + task: () => + new Promise((resolve) => { + releaseTask = resolve; + }), + stop, + isRunning: () => running, + }), + ) + .mockImplementationOnce(() => + makeRunnerStub({ + task: async () => { + abort.abort(); + }, + }), + ); + + const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1)); + const firstSignal = createTelegramBotCalls[0]?.fetchAbortSignal; + expect(firstSignal).toBeInstanceOf(AbortSignal); + expect((firstSignal as AbortSignal).aborted).toBe(false); + + expect(emitUnhandledRejection(new TypeError("fetch failed"))).toBe(true); + await monitor; + + expect((firstSignal as AbortSignal).aborted).toBe(true); + expect(stop).toHaveBeenCalled(); + }); + it("passes configured webhookHost to webhook listener", async () => { await monitorTelegramProvider({ token: "tok", diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 6325670f298cf..29be5e05fea3f 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -113,6 +113,7 @@ const isGrammyHttpError = (err: unknown): boolean => { export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const log = opts.runtime?.error ?? console.error; let activeRunner: ReturnType | undefined; + let activeFetchAbort: AbortController | undefined; let forceRestarted = false; // Register handler for Grammy HttpError unhandled rejections. @@ -129,6 +130,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { // polling stuck; force-stop the active runner so the loop can recover. if (isNetworkError && activeRunner && activeRunner.isRunning()) { forceRestarted = true; + activeFetchAbort?.abort(); void activeRunner.stop().catch(() => {}); log( `[telegram] Restarting polling after unhandled network error: ${formatErrorMessage(err)}`, @@ -241,7 +243,9 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { ); }; - const createPollingBot = async (): Promise => { + const createPollingBot = async ( + fetchAbortController: AbortController, + ): Promise => { try { return createTelegramBot({ token, @@ -249,6 +253,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { proxyFetch, config: cfg, accountId: account.accountId, + fetchAbortSignal: fetchAbortController.signal, updateOffset: { lastUpdateId, onUpdateId: persistUpdateId, @@ -298,7 +303,10 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } }; - const runPollingCycle = async (bot: TelegramBot): Promise<"continue" | "exit"> => { + const runPollingCycle = async ( + bot: TelegramBot, + fetchAbortController: AbortController, + ): Promise<"continue" | "exit"> => { // Confirm the persisted offset with Telegram so the runner (which starts // at offset 0) does not re-fetch already-processed updates on restart. await confirmPersistedOffset(bot); @@ -317,6 +325,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { let stopPromise: Promise | undefined; let stalledRestart = false; const stopRunner = () => { + fetchAbortController.abort(); stopPromise ??= Promise.resolve(runner.stop()) .then(() => undefined) .catch(() => { @@ -393,12 +402,20 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { opts.abortSignal?.removeEventListener("abort", stopOnAbort); await stopRunner(); await stopBot(); + if (activeFetchAbort === fetchAbortController) { + activeFetchAbort = undefined; + } } }; while (!opts.abortSignal?.aborted) { - const bot = await createPollingBot(); + const fetchAbortController = new AbortController(); + activeFetchAbort = fetchAbortController; + const bot = await createPollingBot(fetchAbortController); if (!bot) { + if (activeFetchAbort === fetchAbortController) { + activeFetchAbort = undefined; + } continue; } @@ -410,7 +427,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { return; } - const state = await runPollingCycle(bot); + const state = await runPollingCycle(bot, fetchAbortController); if (state === "exit") { return; } From 6186f620d29ffa592fa11b379cd76fa757032e54 Mon Sep 17 00:00:00 2001 From: George Kalogirou Date: Mon, 23 Feb 2026 01:22:02 +0200 Subject: [PATCH 236/311] fix(telegram): use manual signal forwarding to avoid cross-realm AbortSignal AbortSignal.any() fails in Node.js when signals come from different module contexts (grammY's internal signal vs local AbortController), producing: "The signals[0] argument must be an instance of AbortSignal. Received an instance of AbortSignal". Replace with manual event forwarding that works across all realms. Co-Authored-By: Claude Opus 4.6 --- src/telegram/bot.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index b7bb8c34e60ca..24decd85670b4 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -119,6 +119,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { // Cast baseFetch to global fetch to avoid node-fetch ↔ global-fetch type divergence; // they are runtime-compatible (the codebase already casts at every fetch boundary). const callFetch = baseFetch as unknown as typeof globalThis.fetch; + // Use manual event forwarding instead of AbortSignal.any() to avoid the cross-realm + // AbortSignal issue in Node.js (grammY's signal may come from a different module context, + // causing "signals[0] must be an instance of AbortSignal" errors). finalFetch = ((input: RequestInfo | URL, init?: RequestInit) => { const controller = new AbortController(); const abortWith = (signal: AbortSignal) => controller.abort(signal.reason); From 2d5e70f3e74d2b6c642f5028853ffc753d454356 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:03:28 +0000 Subject: [PATCH 237/311] fix: abort telegram getupdates on shutdown (#23950) (thanks @Gkinthecodeland) --- CHANGELOG.md | 1 + src/telegram/bot.create-telegram-bot.test.ts | 21 ------------ src/telegram/bot.fetch-abort.test.ts | 34 ++++++++++++++++++++ src/telegram/bot.ts | 3 +- 4 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 src/telegram/bot.fetch-abort.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b4cbe4a93fd..685b1ea16d65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis. - Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468. - Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat. +- Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale `getUpdates` long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland. - Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference. ## 2026.3.7 diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 07edc4f5432fe..378c1eb106514 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -75,27 +75,6 @@ describe("createTelegramBot", () => { globalThis.fetch = originalFetch; } }); - it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => { - const originalFetch = globalThis.fetch; - const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => init?.signal); - const shutdown = new AbortController(); - globalThis.fetch = fetchSpy as unknown as typeof fetch; - try { - createTelegramBot({ token: "tok", fetchAbortSignal: shutdown.signal }); - const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) - ?.client?.fetch as ((input: RequestInfo | URL, init?: RequestInit) => Promise); - expect(clientFetch).toBeTypeOf("function"); - - const observedSignal = (await clientFetch("https://example.test")) as AbortSignal; - expect(observedSignal).toBeInstanceOf(AbortSignal); - expect(observedSignal.aborted).toBe(false); - - shutdown.abort(new Error("shutdown")); - expect(observedSignal.aborted).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } - }); it("applies global and per-account timeoutSeconds", () => { loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.fetch-abort.test.ts b/src/telegram/bot.fetch-abort.test.ts new file mode 100644 index 0000000000000..471654686f734 --- /dev/null +++ b/src/telegram/bot.fetch-abort.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js"; +import { createTelegramBot } from "./bot.js"; + +describe("createTelegramBot fetch abort", () => { + it("aborts wrapped client fetch when fetchAbortSignal aborts", async () => { + const originalFetch = globalThis.fetch; + const shutdown = new AbortController(); + const fetchSpy = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + const signal = init?.signal as AbortSignal; + signal.addEventListener("abort", () => resolve(signal), { once: true }); + }), + ); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + try { + botCtorSpy.mockClear(); + createTelegramBot({ token: "tok", fetchAbortSignal: shutdown.signal }); + const clientFetch = (botCtorSpy.mock.calls.at(-1)?.[1] as { client?: { fetch?: unknown } }) + ?.client?.fetch as (input: RequestInfo | URL, init?: RequestInit) => Promise; + expect(clientFetch).toBeTypeOf("function"); + + const observedSignalPromise = clientFetch("https://example.test"); + shutdown.abort(new Error("shutdown")); + const observedSignal = (await observedSignalPromise) as AbortSignal; + + expect(observedSignal).toBeInstanceOf(AbortSignal); + expect(observedSignal.aborted).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 24decd85670b4..8bfa0b8ac0cac 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -110,8 +110,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { // (especially long-polling getUpdates) aborts immediately on shutdown. Without this, // the in-flight getUpdates hangs for up to 30s, and a new gateway instance starting // its own poll triggers a 409 Conflict from Telegram. - let finalFetch: NonNullable | undefined = - shouldProvideFetch && fetchImpl ? fetchForClient : undefined; + let finalFetch = shouldProvideFetch && fetchImpl ? fetchForClient : undefined; if (opts.fetchAbortSignal) { const baseFetch = finalFetch ?? (globalThis.fetch as unknown as NonNullable); From 79853aca9c7f8b9d55dabacc288357b6a6e7eb6c Mon Sep 17 00:00:00 2001 From: rexlunae Date: Tue, 17 Feb 2026 05:43:22 +0000 Subject: [PATCH 238/311] fix(cron): stagger missed jobs on restart to prevent gateway overload When the gateway restarts with many overdue cron jobs, they are now executed with staggered delays to prevent overwhelming the gateway. - Add missedJobStaggerMs config (default 5s between jobs) - Add maxMissedJobsPerRestart limit (default 5 jobs immediately) - Prioritize most overdue jobs by sorting by nextRunAtMs - Reschedule deferred jobs to fire gradually via normal timer Fixes #18892 --- src/cron/service.restart-catchup.test.ts | 60 +++++++++++++++++++++ src/cron/service/state.ts | 12 +++++ src/cron/service/timer.ts | 67 ++++++++++++++++++++---- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/src/cron/service.restart-catchup.test.ts b/src/cron/service.restart-catchup.test.ts index 307af0f9cb401..6dff6efc53009 100644 --- a/src/cron/service.restart-catchup.test.ts +++ b/src/cron/service.restart-catchup.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; +import { createCronServiceState } from "./state.js"; import { setupCronServiceSuite } from "./service.test-harness.js"; +import { runMissedJobs } from "./timer.js"; const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({ prefix: "openclaw-cron-", @@ -30,6 +32,21 @@ describe("CronService restart catch-up", () => { }); } + function createOverdueEveryJob(id: string, nextRunAtMs: number) { + return { + id, + name: `job-${id}`, + enabled: true, + createdAtMs: nextRunAtMs - 60_000, + updatedAtMs: nextRunAtMs - 60_000, + schedule: { kind: "every", everyMs: 60_000, anchorMs: nextRunAtMs - 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: `tick-${id}` }, + state: { nextRunAtMs }, + }; + } + it("executes an overdue recurring job immediately on start", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); @@ -351,4 +368,47 @@ describe("CronService restart catch-up", () => { cron.stop(); await store.cleanup(); }); + + it("reschedules deferred missed jobs from the post-catchup clock so they stay in the future", async () => { + const store = await makeStorePath(); + const startNow = Date.parse("2025-12-13T17:00:00.000Z"); + let now = startNow; + + await writeStoreJobs(store.storePath, [ + createOverdueEveryJob("stagger-0", startNow - 60_000), + createOverdueEveryJob("stagger-1", startNow - 50_000), + createOverdueEveryJob("stagger-2", startNow - 40_000), + ]); + + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => now, + enqueueSystemEvent: vi.fn(), + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => { + now += 6_000; + return { status: "ok" as const, summary: "ok" }; + }), + maxMissedJobsPerRestart: 1, + missedJobStaggerMs: 5_000, + }); + + await runMissedJobs(state); + + const staggeredJobs = (state.store?.jobs ?? []) + .filter((job) => job.id.startsWith("stagger-") && job.id !== "stagger-0") + .toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); + + expect(staggeredJobs).toHaveLength(2); + expect(staggeredJobs[0]?.state.nextRunAtMs).toBeGreaterThan(now); + expect(staggeredJobs[1]?.state.nextRunAtMs).toBeGreaterThan( + staggeredJobs[0]?.state.nextRunAtMs ?? 0, + ); + expect((staggeredJobs[1]?.state.nextRunAtMs ?? 0) - (staggeredJobs[0]?.state.nextRunAtMs ?? 0)) + .toBe(5_000); + + await store.cleanup(); + }); }); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 1e42ae089cd7e..073efd8f459ce 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -48,6 +48,18 @@ export type CronServiceDeps = { resolveSessionStorePath?: (agentId?: string) => string; /** Path to the session store (sessions.json) for reaper use. */ sessionStorePath?: string; + /** + * Delay in ms between missed job executions on startup. + * Prevents overwhelming the gateway when many jobs are overdue. + * See: https://github.com/openclaw/openclaw/issues/18892 + */ + missedJobStaggerMs?: number; + /** + * Maximum number of missed jobs to run immediately on startup. + * Additional missed jobs will be rescheduled to fire gradually. + * See: https://github.com/openclaw/openclaw/issues/18892 + */ + maxMissedJobsPerRestart?: number; enqueueSystemEvent: ( text: string, opts?: { agentId?: string; sessionKey?: string; contextKey?: string }, diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 3f50ca757e886..8f005bd8dbbdc 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -38,6 +38,9 @@ const MAX_TIMER_DELAY_MS = 60_000; * but always breaks an infinite re-trigger cycle. (See #17821) */ const MIN_REFIRE_GAP_MS = 2_000; + +const DEFAULT_MISSED_JOB_STAGGER_MS = 5_000; +const DEFAULT_MAX_MISSED_JOBS_PER_RESTART = 5; const DEFAULT_FAILURE_ALERT_AFTER = 2; const DEFAULT_FAILURE_ALERT_COOLDOWN_MS = 60 * 60_000; // 1 hour @@ -829,10 +832,18 @@ export async function runMissedJobs( state: CronServiceState, opts?: { skipJobIds?: ReadonlySet }, ) { - const startupCandidates = await locked(state, async () => { + const staggerMs = Math.max(0, state.deps.missedJobStaggerMs ?? DEFAULT_MISSED_JOB_STAGGER_MS); + const maxImmediate = Math.max( + 0, + state.deps.maxMissedJobsPerRestart ?? DEFAULT_MAX_MISSED_JOBS_PER_RESTART, + ); + const selection = await locked(state, async () => { await ensureLoaded(state, { skipRecompute: true }); if (!state.store) { - return [] as Array<{ jobId: string; job: CronJob }>; + return { + deferredJobIds: [] as string[], + startupCandidates: [] as Array<{ jobId: string; job: CronJob }>, + }; } const now = state.deps.nowMs(); const skipJobIds = opts?.skipJobIds; @@ -842,26 +853,47 @@ export async function runMissedJobs( allowCronMissedRunByLastRun: true, }); if (missed.length === 0) { - return [] as Array<{ jobId: string; job: CronJob }>; + return { + deferredJobIds: [] as string[], + startupCandidates: [] as Array<{ jobId: string; job: CronJob }>, + }; } - state.deps.log.info( - { count: missed.length, jobIds: missed.map((j) => j.id) }, - "cron: running missed jobs after restart", - ); - for (const job of missed) { + const sorted = missed.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); + const startupCandidates = sorted.slice(0, maxImmediate); + const deferred = sorted.slice(maxImmediate); + if (deferred.length > 0) { + state.deps.log.info( + { + immediateCount: startupCandidates.length, + deferredCount: deferred.length, + totalMissed: missed.length, + }, + "cron: staggering missed jobs to prevent gateway overload", + ); + } + if (startupCandidates.length > 0) { + state.deps.log.info( + { count: startupCandidates.length, jobIds: startupCandidates.map((j) => j.id) }, + "cron: running missed jobs after restart", + ); + } + for (const job of startupCandidates) { job.state.runningAtMs = now; job.state.lastError = undefined; } await persist(state); - return missed.map((job) => ({ jobId: job.id, job })); + return { + deferredJobIds: deferred.map((job) => job.id), + startupCandidates: startupCandidates.map((job) => ({ jobId: job.id, job })), + }; }); - if (startupCandidates.length === 0) { + if (selection.startupCandidates.length === 0 && selection.deferredJobIds.length === 0) { return; } const outcomes: Array = []; - for (const candidate of startupCandidates) { + for (const candidate of selection.startupCandidates) { const startedAt = state.deps.nowMs(); emit(state, { jobId: candidate.job.id, action: "started", runAtMs: startedAt }); try { @@ -901,6 +933,19 @@ export async function runMissedJobs( applyOutcomeToStoredJob(state, result); } + if (selection.deferredJobIds.length > 0) { + const baseNow = state.deps.nowMs(); + let offset = staggerMs; + for (const jobId of selection.deferredJobIds) { + const job = state.store.jobs.find((entry) => entry.id === jobId); + if (!job || !job.enabled) { + continue; + } + job.state.nextRunAtMs = baseNow + offset; + offset += staggerMs; + } + } + // Preserve any new past-due nextRunAtMs values that became due while // startup catch-up was running. They should execute on a future tick // instead of being silently advanced. From 96d17f3cb19109424aca7b82074c33118984cdb9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:07:25 +0000 Subject: [PATCH 239/311] fix: stagger missed cron jobs on restart (#18925) (thanks @rexlunae) --- CHANGELOG.md | 1 + src/cron/service.restart-catchup.test.ts | 9 +++++---- src/cron/service/timer.ts | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 685b1ea16d65f..208822084ee0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai - Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468. - Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat. - Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale `getUpdates` long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland. +- Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae. - Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference. ## 2026.3.7 diff --git a/src/cron/service.restart-catchup.test.ts b/src/cron/service.restart-catchup.test.ts index 6dff6efc53009..f0c9c3e4dc930 100644 --- a/src/cron/service.restart-catchup.test.ts +++ b/src/cron/service.restart-catchup.test.ts @@ -2,9 +2,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; -import { createCronServiceState } from "./state.js"; import { setupCronServiceSuite } from "./service.test-harness.js"; -import { runMissedJobs } from "./timer.js"; +import { createCronServiceState } from "./service/state.js"; +import { runMissedJobs } from "./service/timer.js"; const { logger: noopLogger, makeStorePath } = setupCronServiceSuite({ prefix: "openclaw-cron-", @@ -406,8 +406,9 @@ describe("CronService restart catch-up", () => { expect(staggeredJobs[1]?.state.nextRunAtMs).toBeGreaterThan( staggeredJobs[0]?.state.nextRunAtMs ?? 0, ); - expect((staggeredJobs[1]?.state.nextRunAtMs ?? 0) - (staggeredJobs[0]?.state.nextRunAtMs ?? 0)) - .toBe(5_000); + expect( + (staggeredJobs[1]?.state.nextRunAtMs ?? 0) - (staggeredJobs[0]?.state.nextRunAtMs ?? 0), + ).toBe(5_000); await store.cleanup(); }); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 8f005bd8dbbdc..08b4b6be20603 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -858,7 +858,9 @@ export async function runMissedJobs( startupCandidates: [] as Array<{ jobId: string; job: CronJob }>, }; } - const sorted = missed.toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); + const sorted = missed.toSorted( + (a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0), + ); const startupCandidates = sorted.slice(0, maxImmediate); const deferred = sorted.slice(maxImmediate); if (deferred.length > 0) { From 2e79d821981bb9f1bf8cac8318b223b00b6316b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:09:22 +0000 Subject: [PATCH 240/311] build: update app deps except carbon --- extensions/matrix/package.json | 2 +- package.json | 12 +- pnpm-lock.yaml | 497 +++++++++++++++--- src/agents/anthropic-payload-log.test.ts | 4 +- src/agents/anthropic-payload-log.ts | 4 +- src/agents/auth-profiles/oauth.ts | 8 +- src/agents/openai-ws-stream.ts | 2 +- ...i-embedded-runner-extraparams.live.test.ts | 9 +- .../pi-embedded-runner-extraparams.test.ts | 40 +- .../anthropic-stream-wrappers.ts | 4 +- .../extra-params.kilocode.test.ts | 8 +- ...ra-params.openrouter-cache-control.test.ts | 2 +- src/agents/pi-embedded-runner/extra-params.ts | 12 +- .../extra-params.zai-tool-stream.test.ts | 4 +- .../moonshot-stream-wrappers.ts | 8 +- .../openai-stream-wrappers.ts | 8 +- .../proxy-stream-wrappers.ts | 12 +- .../pi-embedded-runner/run/attempt.test.ts | 2 +- src/agents/pi-embedded-runner/run/attempt.ts | 7 +- src/commands/openai-codex-oauth.ts | 6 +- ...unction-call-comes-after-user-turn.test.ts | 4 +- ...eserves-parameters-type-is-missing.test.ts | 7 +- src/providers/google-shared.test-helpers.ts | 2 +- 23 files changed, 495 insertions(+), 169 deletions(-) diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index f32e8915436ce..1b769b65011fd 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { - "@mariozechner/pi-agent-core": "0.55.3", + "@mariozechner/pi-agent-core": "0.57.1", "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "@vector-im/matrix-bot-sdk": "0.8.0-element.3", "markdown-it": "14.1.1", diff --git a/package.json b/package.json index 753fe15a0598e..5f6d8930124cc 100644 --- a/package.json +++ b/package.json @@ -344,10 +344,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.55.3", - "@mariozechner/pi-ai": "0.55.3", - "@mariozechner/pi-coding-agent": "0.55.3", - "@mariozechner/pi-tui": "0.55.3", + "@mariozechner/pi-agent-core": "0.57.1", + "@mariozechner/pi-ai": "0.57.1", + "@mariozechner/pi-coding-agent": "0.57.1", + "@mariozechner/pi-tui": "0.57.1", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", @@ -380,7 +380,7 @@ "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "sqlite-vec": "0.1.7-alpha.2", - "tar": "7.5.10", + "tar": "7.5.11", "tslog": "^4.10.2", "undici": "^7.22.0", "ws": "^8.19.0", @@ -396,7 +396,7 @@ "@types/node": "^25.3.5", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20260307.1", + "@typescript/native-preview": "7.0.0-dev.20260308.1", "@vitest/coverage-v8": "^4.0.18", "jscpd": "4.0.8", "lit": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de39720b6a966..2e57a623a315a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,17 +58,17 @@ importers: specifier: 1.2.0-beta.3 version: 1.2.0-beta.3 '@mariozechner/pi-agent-core': - specifier: 0.55.3 - version: 0.55.3(ws@8.19.0)(zod@4.3.6) + specifier: 0.57.1 + version: 0.57.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: 0.55.3 - version: 0.55.3(ws@8.19.0)(zod@4.3.6) + specifier: 0.57.1 + version: 0.57.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': - specifier: 0.55.3 - version: 0.55.3(ws@8.19.0)(zod@4.3.6) + specifier: 0.57.1 + version: 0.57.1(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-tui': - specifier: 0.55.3 - version: 0.55.3 + specifier: 0.57.1 + version: 0.57.1 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -172,8 +172,8 @@ importers: specifier: 0.1.7-alpha.2 version: 0.1.7-alpha.2 tar: - specifier: 7.5.10 - version: 7.5.10 + specifier: 7.5.11 + version: 7.5.11 tslog: specifier: ^4.10.2 version: 4.10.2 @@ -215,8 +215,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260307.1 - version: 7.0.0-dev.20260307.1 + specifier: 7.0.0-dev.20260308.1 + version: 7.0.0-dev.20260308.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.5)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) @@ -240,7 +240,7 @@ importers: version: 0.21.1(signal-polyfill@0.2.2) tsdown: specifier: 0.21.0 - version: 0.21.0(@typescript/native-preview@7.0.0-dev.20260307.1)(typescript@5.9.3) + version: 0.21.0(@typescript/native-preview@7.0.0-dev.20260308.1)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -369,8 +369,8 @@ importers: extensions/matrix: dependencies: '@mariozechner/pi-agent-core': - specifier: 0.55.3 - version: 0.55.3(ws@8.19.0)(zod@4.3.6) + specifier: 0.57.1 + version: 0.57.1(ws@8.19.0)(zod@4.3.6) '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 @@ -622,6 +622,10 @@ packages: resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1004.0': + resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock@3.1000.0': resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==} engines: {node: '>=20.0.0'} @@ -710,6 +714,10 @@ packages: resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.10': + resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.9': resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==} engines: {node: '>=20.0.0'} @@ -722,6 +730,10 @@ packages: resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.7': + resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-expect-continue@3.972.6': resolution: {integrity: sha512-QMdffpU+GkSGC+bz6WdqlclqIeCsOfgX8JFZ5xvwDtX+UTj4mIXm3uXu7Ko6dBseRcJz1FA6T9OmlAAY6JgJUg==} engines: {node: '>=20.0.0'} @@ -778,6 +790,10 @@ packages: resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==} engines: {node: '>= 14.0.0'} + '@aws-sdk/middleware-websocket@3.972.12': + resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.996.3': resolution: {integrity: sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==} engines: {node: '>=20.0.0'} @@ -838,6 +854,10 @@ packages: resolution: {integrity: sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.7': + resolution: {integrity: sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} @@ -1211,6 +1231,15 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@google/genai@1.44.0': + resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@grammyjs/runner@2.0.3': resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} engines: {node: '>=12.20.0 || >=14.13.1'} @@ -1619,20 +1648,38 @@ packages: resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-agent-core@0.57.1': + resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==} + engines: {node: '>=20.0.0'} + '@mariozechner/pi-ai@0.55.3': resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-ai@0.57.1': + resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==} + engines: {node: '>=20.0.0'} + hasBin: true + '@mariozechner/pi-coding-agent@0.55.3': resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==} engines: {node: '>=20.0.0'} hasBin: true + '@mariozechner/pi-coding-agent@0.57.1': + resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==} + engines: {node: '>=20.6.0'} + hasBin: true + '@mariozechner/pi-tui@0.55.3': resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==} engines: {node: '>=20.0.0'} + '@mariozechner/pi-tui@0.57.1': + resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==} + engines: {node: '>=20.0.0'} + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} @@ -1648,6 +1695,9 @@ packages: '@mistralai/mistralai@1.10.0': resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==} + '@mistralai/mistralai@1.14.1': + resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} + '@mozilla/readability@0.6.0': resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} @@ -2800,22 +2850,42 @@ packages: resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.11': + resolution: {integrity: sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==} + 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.11': + resolution: {integrity: sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==} + 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.11': + resolution: {integrity: sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==} + 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.11': + resolution: {integrity: sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==} + 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.11': + resolution: {integrity: sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.11': resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} engines: {node: '>=18.0.0'} @@ -3221,12 +3291,12 @@ packages: '@swc/helpers@0.5.19': resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} - '@thi.ng/bitstream@2.4.41': - resolution: {integrity: sha512-treRzw3+7I1YCuilFtznwT3SGtceS9spUXhyBqeuKNTm4nIfMuvg4fNqx4GgpuS6cGPQNPMUJm0OyzKnSe2Emw==} + '@thi.ng/bitstream@2.4.43': + resolution: {integrity: sha512-tObOEr+osboa0kqQPk7Ny0E3vVfBRch13YJO5RpaDDSkMQmoXK/pw3yW/6kKJIObt27YQol6pGlOZBvB8MsghQ==} engines: {node: '>=18'} - '@thi.ng/errors@2.6.3': - resolution: {integrity: sha512-owkOOKHf7MrAPN2jNpKWDdY/vjtPFiJf6oxZ3jkkhV6ICTu2iY1fXIR2wQ7kVEeybdtb0w24k2PtrU43OYCWdg==} + '@thi.ng/errors@2.6.5': + resolution: {integrity: sha512-XKfcJzxikMI1+MKSiABcLzI2WIsm4SxGEdLIIQjYqew3q3CoypGe+w5W/DMvMWF6eFWT6ONINbiJ6QMHFTfVzA==} engines: {node: '>=18'} '@tinyhttp/content-disposition@2.2.4': @@ -3297,8 +3367,8 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/aws-lambda@8.10.160': - resolution: {integrity: sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA==} + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -3378,8 +3448,8 @@ packages: '@types/node@10.17.60': resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} - '@types/node@20.19.35': - resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} '@types/node@24.11.0': resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} @@ -3432,43 +3502,43 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-VpnrMP4iDLSTT9Hg/KrHwuIHLZr5dxYPMFErfv3ZDA0tv48u2H1lBhHVVMMopCuskuX3C35EOJbxLkxCJd6zDw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-mywkctYr45fUBUYD35poInc9HEjup0zyCO5z3ZU2QC9eCQShpwYSDceoSCwxVKB/b/f/CU6H3LqINFeIz5CvrQ==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-+4akGPxwfrPy2AYmQO1bp6CXxUVlBPrL0lSv+wY/E8vNGqwF0UtJCwAcR54ae1+k9EmoirT7Xn6LE3Io6mXntg==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-iF+Y4USbCiD5BxmXI6xYuy+S6d2BhxKDb3YHjchzqg3AgleDNTd2rqSzlWv4ku26V2iOSfpM9t1H/xluL9pgNw==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-u4kXuHN2p+HeWsnTixoEOwALsCoS+n3/ukWdnV/mwyg6BKuuU69qCv3/miY6YPFtE7mUwzPdflEXsvkZJbJ/RA==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-uEIIbW1JYPGEesVh/P5xA+xox7pQ6toeFPeke2X2H2bs5YkWHVaUQtVZuKNmGelw+2PCG6XRrXvMgMp056ebuQ==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-E0Pve6BjTVvPiHq9cPVQu6fbW/Qo/CEs1VN2NMILd0xzFVpVd9FIvzV+Ft6pZilu1SBcihThW3sQ92l03Cw2+Q==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-vg8hwfwIhT8CmYJI5lG3PP8IoNzKKBGbq1cKjxQabSZTPuQKwVFVity2XKTKZKd+qRGL7xW4UWMJZLFgSx3b2Q==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-MzuRjTYQIS7XrJcH0As18SbaQU+rFhf9LCpXs2QeHjhXQ33wjuFDNhQeurg2eKm6A0xE0GoW9K+sKsm8bhzzPg==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-Yd/ht0CGE4NYUAjuHa1u4VbiJbyUgvDh+b2o+Zcb2h5t8B761DIzDm24QqVXh+KhvGUoEodXWg3g3APxLHqj8Q==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-UNZl8Q6lx1njEPU8+FNjYvqii5PtDjk6cyxmVPwwJI2Snz5T5qY6oadkUds6CJsLkt7s4UB3P5XgLu1+vwoYGw==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-Klk6BoiHegfPmkO0YYrXmbYVdPjOfN25lRkzenqDIwbyzPlABHvICCyo5YRvWD3HU4EeDfLisIFU9wEd/0duCw==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-aPJb4v0Df9GzWFWbO4YbLg0OjmjxZgXngkF1M746r4CgOdydWgosNPWypzzAwiliGKvCLwfAWYiV+T5Jf1vQ3g==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-4LrXmaMfzedwczANIkD/M9guPD4EWuQnCxOJsJkdYi3ExWQDjIFwfmxTtAmfPBWxVExLfn7UUkz/yCtcv2Wd+w==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260307.1': - resolution: {integrity: sha512-NcKdPiGjxxxdh7fLgRKTrn5hLntbt89NOodNaSrMChTfJwvLaDkgrRlnO7v5x+m7nQc87Qf1y7UoT1ZEZUBB4Q==} + '@typescript/native-preview@7.0.0-dev.20260308.1': + resolution: {integrity: sha512-8a3oe5IAfBkEfMouRheNhOXUScBSHIUknPvUdsbxx7s+Ja1lxFNA1X1TTl2T18vu72Q/mM86vxefw5eW8/ps3g==} hasBin: true '@typespec/ts-http-runtime@0.3.3': @@ -3825,8 +3895,8 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -3981,8 +4051,8 @@ packages: resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} engines: {node: '>=4.0.0'} - command-line-usage@7.0.3: - resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==} + command-line-usage@7.0.4: + resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} engines: {node: '>=12.20.0'} commander@10.0.1: @@ -4442,6 +4512,10 @@ packages: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -4838,8 +4912,8 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - json-with-bigint@3.5.3: - resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==} + json-with-bigint@3.5.7: + resolution: {integrity: sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==} json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} @@ -5260,8 +5334,8 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - node-addon-api@8.5.0: - resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + node-addon-api@8.6.0: + resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} engines: {node: ^18 || ^20 || >= 21} node-api-headers@1.8.0: @@ -5404,6 +5478,18 @@ packages: zod: optional: true + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openai@6.27.0: resolution: {integrity: sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==} hasBin: true @@ -6214,6 +6300,10 @@ packages: resolution: {integrity: sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==} engines: {node: '>=18'} + tar@7.5.11: + resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} + engines: {node: '>=18'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -6784,6 +6874,58 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock-runtime@3.1004.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.18 + '@aws-sdk/credential-provider-node': 3.972.18 + '@aws-sdk/eventstream-handler-node': 3.972.10 + '@aws-sdk/middleware-eventstream': 3.972.7 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/middleware-websocket': 3.972.12 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/token-providers': 3.1004.0 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.4 + '@smithy/config-resolver': 4.4.10 + '@smithy/core': 3.23.9 + '@smithy/eventstream-serde-browser': 4.2.11 + '@smithy/eventstream-serde-config-resolver': 4.3.11 + '@smithy/eventstream-serde-node': 4.2.11 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/hash-node': 4.2.11 + '@smithy/invalid-dependency': 4.2.11 + '@smithy/middleware-content-length': 4.2.11 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-retry': 4.4.40 + '@smithy/middleware-serde': 4.2.12 + '@smithy/middleware-stack': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/node-http-handler': 4.4.14 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.39 + '@smithy/util-defaults-mode-node': 4.2.42 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/util-stream': 4.5.17 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-bedrock@3.1000.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7179,6 +7321,13 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/eventstream-handler-node@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/eventstream-codec': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.9': dependencies: '@aws-sdk/types': 3.973.4 @@ -7203,6 +7352,13 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/middleware-expect-continue@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -7334,6 +7490,21 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-format-url': 3.972.7 + '@smithy/eventstream-codec': 4.2.11 + '@smithy/eventstream-serde-browser': 4.2.11 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/protocol-http': 5.3.11 + '@smithy/signature-v4': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.996.3': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7529,6 +7700,13 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/querystring-builder': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.4': dependencies: tslib: 2.8.1 @@ -7808,7 +7986,7 @@ snapshots: '@discordjs/opus@0.10.0': dependencies: '@discordjs/node-pre-gyp': 0.4.5 - node-addon-api: 8.5.0 + node-addon-api: 8.6.0 transitivePeerDependencies: - encoding - supports-color @@ -7937,6 +8115,17 @@ snapshots: - supports-color - utf-8-validate + '@google/genai@1.44.0': + dependencies: + google-auth-library: 10.6.1 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@grammyjs/runner@2.0.3(grammy@1.41.0)': dependencies: abort-controller: 3.0.0 @@ -8328,6 +8517,18 @@ snapshots: - ws - zod + '@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/pi-ai': 0.57.1(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.55.3(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -8352,6 +8553,30 @@ snapshots: - ws - zod + '@mariozechner/pi-ai@0.57.1(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.1004.0 + '@google/genai': 1.44.0 + '@mistralai/mistralai': 1.14.1 + '@sinclair/typebox': 0.34.48 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + chalk: 5.6.2 + openai: 6.26.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.55.3(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -8383,6 +8608,38 @@ snapshots: - ws - zod + '@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)': + dependencies: + '@mariozechner/jiti': 2.6.5 + '@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.57.1 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cli-highlight: 2.1.11 + diff: 8.0.3 + extract-zip: 2.0.1 + 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.4 + proper-lockfile: 4.1.2 + strip-ansi: 7.2.0 + undici: 7.22.0 + 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.55.3': dependencies: '@types/mime-types': 2.1.4 @@ -8392,6 +8649,16 @@ snapshots: marked: 15.0.12 mime-types: 3.0.2 + '@mariozechner/pi-tui@0.57.1': + dependencies: + '@types/mime-types': 2.1.4 + chalk: 5.6.2 + get-east-asian-width: 1.5.0 + marked: 15.0.12 + mime-types: 3.0.2 + optionalDependencies: + koffi: 2.15.1 + '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': dependencies: https-proxy-agent: 7.0.6 @@ -8426,6 +8693,15 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.25.1(zod@3.25.76) + '@mistralai/mistralai@1.14.1': + dependencies: + ws: 8.19.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@mozilla/readability@0.6.0': {} '@napi-rs/canvas-android-arm64@0.1.95': @@ -8625,7 +8901,7 @@ snapshots: '@octokit/core': 7.0.6 '@octokit/oauth-authorization-url': 8.0.0 '@octokit/oauth-methods': 6.0.2 - '@types/aws-lambda': 8.10.160 + '@types/aws-lambda': 8.10.161 universal-user-agent: 7.0.3 '@octokit/oauth-authorization-url@8.0.0': {} @@ -8678,7 +8954,7 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/types': 16.0.0 fast-content-type-parse: 3.0.0 - json-with-bigint: 3.5.3 + json-with-bigint: 3.5.7 universal-user-agent: 7.0.3 '@octokit/types@16.0.0': @@ -9481,29 +9757,59 @@ snapshots: '@smithy/util-hex-encoding': 4.2.1 tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.11': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.2 + 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.11': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.11 + '@smithy/types': 4.13.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.11': + dependencies: + '@smithy/types': 4.13.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.11': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.11 + '@smithy/types': 4.13.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.11': + dependencies: + '@smithy/eventstream-codec': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.11': dependencies: '@smithy/protocol-http': 5.3.10 @@ -10056,12 +10362,12 @@ snapshots: dependencies: tslib: 2.8.1 - '@thi.ng/bitstream@2.4.41': + '@thi.ng/bitstream@2.4.43': dependencies: - '@thi.ng/errors': 2.6.3 + '@thi.ng/errors': 2.6.5 optional: true - '@thi.ng/errors@2.6.3': + '@thi.ng/errors@2.6.5': optional: true '@tinyhttp/content-disposition@2.2.4': {} @@ -10172,7 +10478,7 @@ snapshots: tslib: 2.8.1 optional: true - '@types/aws-lambda@8.10.160': {} + '@types/aws-lambda@8.10.161': {} '@types/body-parser@1.19.6': dependencies: @@ -10266,7 +10572,7 @@ snapshots: '@types/node@10.17.60': {} - '@types/node@20.19.35': + '@types/node@20.19.37': dependencies: undici-types: 6.21.0 @@ -10330,36 +10636,36 @@ snapshots: '@types/node': 25.3.5 optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260307.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260307.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260307.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260307.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260307.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260307.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260307.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260308.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260307.1': + '@typescript/native-preview@7.0.0-dev.20260308.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260307.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260307.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260307.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260307.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260307.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260307.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260307.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260308.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260308.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260308.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260308.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260308.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260308.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260308.1 '@typespec/ts-http-runtime@0.3.3': dependencies: @@ -10613,9 +10919,9 @@ snapshots: '@swc/helpers': 0.5.19 '@types/command-line-args': 5.2.3 '@types/command-line-usage': 5.0.4 - '@types/node': 20.19.35 + '@types/node': 20.19.37 command-line-args: 5.2.1 - command-line-usage: 7.0.3 + command-line-usage: 7.0.4 flatbuffers: 24.12.23 json-bignum: 0.0.3 tslib: 2.8.1 @@ -10785,7 +11091,7 @@ snapshots: bowser@2.14.1: {} - brace-expansion@5.0.3: + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -10908,7 +11214,7 @@ snapshots: cmake-js@8.0.0: dependencies: debug: 4.4.3 - fs-extra: 11.3.3 + fs-extra: 11.3.4 node-api-headers: 1.8.0 rc: 1.2.8 semver: 7.7.4 @@ -10946,7 +11252,7 @@ snapshots: lodash.camelcase: 4.3.0 typical: 4.0.0 - command-line-usage@7.0.3: + command-line-usage@7.0.4: dependencies: array-back: 6.2.2 chalk-template: 0.4.0 @@ -11435,6 +11741,12 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs.realpath@1.0.0: optional: true @@ -11762,7 +12074,7 @@ snapshots: commander: 10.0.1 eventemitter3: 5.0.4 filenamify: 6.0.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 is-unicode-supported: 2.1.0 lifecycle-utils: 2.1.0 lodash.debounce: 4.0.8 @@ -11910,7 +12222,7 @@ snapshots: json-stringify-safe@5.0.1: {} - json-with-bigint@3.5.3: {} + json-with-bigint@3.5.7: {} json5@2.2.3: {} @@ -12249,7 +12561,7 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.3 + brace-expansion: 5.0.4 minimist@1.2.8: {} @@ -12315,7 +12627,7 @@ snapshots: netmask@2.0.2: {} - node-addon-api@8.5.0: {} + node-addon-api@8.6.0: {} node-api-headers@1.8.0: {} @@ -12352,14 +12664,14 @@ snapshots: cross-spawn: 7.0.6 env-var: 7.5.0 filenamify: 6.0.0 - fs-extra: 11.3.3 + fs-extra: 11.3.4 ignore: 7.0.5 ipull: 3.9.5 is-unicode-supported: 2.1.0 lifecycle-utils: 3.1.1 log-symbols: 7.0.1 nanoid: 5.1.6 - node-addon-api: 8.5.0 + node-addon-api: 8.6.0 octokit: 5.0.5 ora: 9.3.0 pretty-ms: 9.3.0 @@ -12503,6 +12815,11 @@ snapshots: ws: 8.19.0 zod: 4.3.6 + openai@6.26.0(ws@8.19.0)(zod@4.3.6): + optionalDependencies: + ws: 8.19.0 + zod: 4.3.6 + openai@6.27.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 @@ -12987,7 +13304,7 @@ snapshots: qoa-format@1.0.1: dependencies: - '@thi.ng/bitstream': 2.4.41 + '@thi.ng/bitstream': 2.4.43 optional: true qrcode-terminal@0.12.0: {} @@ -13117,7 +13434,7 @@ snapshots: dependencies: glob: 10.5.0 - rolldown-plugin-dts@0.22.4(@typescript/native-preview@7.0.0-dev.20260307.1)(rolldown@1.0.0-rc.7)(typescript@5.9.3): + rolldown-plugin-dts@0.22.4(@typescript/native-preview@7.0.0-dev.20260308.1)(rolldown@1.0.0-rc.7)(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.2 @@ -13130,7 +13447,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-rc.7 optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260307.1 + '@typescript/native-preview': 7.0.0-dev.20260308.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -13601,6 +13918,14 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tar@7.5.11: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + text-decoder@1.2.7: dependencies: b4a: 1.8.0 @@ -13665,7 +13990,7 @@ snapshots: ts-algebra@2.0.0: {} - tsdown@0.21.0(@typescript/native-preview@7.0.0-dev.20260307.1)(typescript@5.9.3): + tsdown@0.21.0(@typescript/native-preview@7.0.0-dev.20260308.1)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -13676,7 +14001,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.7 - rolldown-plugin-dts: 0.22.4(@typescript/native-preview@7.0.0-dev.20260307.1)(rolldown@1.0.0-rc.7)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.4(@typescript/native-preview@7.0.0-dev.20260308.1)(rolldown@1.0.0-rc.7)(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index c97eda2f28513..fb3cf18e47d4c 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -28,8 +28,8 @@ describe("createAnthropicPayloadLogger", () => { }, ], }; - const streamFn: StreamFn = ((_, __, options) => { - options?.onPayload?.(payload); + const streamFn: StreamFn = ((model, __, options) => { + options?.onPayload?.(payload, model); return {} as never; }) as StreamFn; diff --git a/src/agents/anthropic-payload-log.ts b/src/agents/anthropic-payload-log.ts index 882a85f0f387b..6bfb3d8d374d3 100644 --- a/src/agents/anthropic-payload-log.ts +++ b/src/agents/anthropic-payload-log.ts @@ -136,7 +136,7 @@ export function createAnthropicPayloadLogger(params: { if (!isAnthropicModel(model)) { return streamFn(model, context, options); } - const nextOnPayload = (payload: unknown) => { + const nextOnPayload = (payload: unknown, payloadModel: Parameters[0]) => { const redactedPayload = redactImageDataForDiagnostics(payload); record({ ...base, @@ -145,7 +145,7 @@ export function createAnthropicPayloadLogger(params: { payload: redactedPayload, payloadDigest: digest(redactedPayload), }); - options?.onPayload?.(payload); + return options?.onPayload?.(payload, payloadModel); }; return streamFn(model, context, { ...options, diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 6f2061501b626..3604fd47b74f3 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -1,9 +1,5 @@ -import { - getOAuthApiKey, - getOAuthProviders, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; +import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai/oauth"; +import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth"; import { loadConfig, type OpenClawConfig } from "../../config/config.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { withFileLock } from "../../infra/file-lock.js"; diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index 9228fd92d4688..e04cac5a7b6ae 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -604,7 +604,7 @@ export function createOpenAIWebSocketStreamFn( ...(prevResponseId ? { previous_response_id: prevResponseId } : {}), ...extraParams, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); try { session.manager.send(payload as Parameters[0]); diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 4116476c71f22..5fa9af21ce037 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -101,7 +101,7 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => { oneByOneRedPngBase64: string; includeImage?: boolean; prompt: string; - onPayload?: (payload: Record) => void; + onPayload?: (payload: Record, model: Model<"google-generative-ai">) => void; }): Promise<{ sawDone: boolean; stopReason?: string; errorMessage?: string }> { const userContent: Array< { type: "text"; text: string } | { type: "image"; mimeType: string; data: string } @@ -129,8 +129,11 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => { apiKey: params.apiKey, reasoning: "high", maxTokens: 64, - onPayload: (payload) => { - params.onPayload?.(payload as Record); + onPayload: (payload, streamModel) => { + params.onPayload?.( + payload as Record, + streamModel as Model<"google-generative-ai">, + ); }, }, ); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index f0762e02f0a7b..18513167a3326 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -207,8 +207,8 @@ describe("applyExtraParamsToAgent", () => { payload?: Record; }) { const payload = params.payload ?? { store: false }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); + const baseStreamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(payload, model); return {} as ReturnType; }; const agent = { streamFn: baseStreamFn }; @@ -232,8 +232,8 @@ describe("applyExtraParamsToAgent", () => { payload?: Record; }) { const payload = params.payload ?? {}; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); + const baseStreamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(payload, model); return {} as ReturnType; }; const agent = { streamFn: baseStreamFn }; @@ -276,7 +276,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { model: "deepseek/deepseek-r1" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -308,7 +308,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -332,7 +332,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -357,7 +357,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning: { max_tokens: 256 } }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -381,7 +381,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "medium" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -588,7 +588,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { thinking: "off" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -619,7 +619,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { thinking: "off" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -650,7 +650,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -674,7 +674,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { tool_choice: "required" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -699,7 +699,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -757,7 +757,7 @@ describe("applyExtraParamsToAgent", () => { ], tool_choice: { type: "tool", name: "read" }, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -820,7 +820,7 @@ describe("applyExtraParamsToAgent", () => { ], tool_choice: input, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -853,7 +853,7 @@ describe("applyExtraParamsToAgent", () => { }, ], }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -892,7 +892,7 @@ describe("applyExtraParamsToAgent", () => { }, ], }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -956,7 +956,7 @@ describe("applyExtraParamsToAgent", () => { }, }, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; @@ -1003,7 +1003,7 @@ describe("applyExtraParamsToAgent", () => { }, }, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); payloads.push(payload); return {} as ReturnType; }; diff --git a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts index 77c5e82f81409..8add7890b412f 100644 --- a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts @@ -277,7 +277,7 @@ export function createAnthropicToolPayloadCompatibilityWrapper( const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if ( payload && typeof payload === "object" && @@ -298,7 +298,7 @@ export function createAnthropicToolPayloadCompatibilityWrapper( ); } } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts index 509cdb5edf430..b2b5174fff4cb 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -19,7 +19,7 @@ function applyAndCapture(params: { const baseStreamFn: StreamFn = (_model, _context, options) => { captured.headers = options?.headers; - options?.onPayload?.({}); + options?.onPayload?.({}, model); return createAssistantMessageEventStream(); }; const agent = { streamFn: baseStreamFn }; @@ -97,7 +97,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); capturedPayload = payload; return createAssistantMessageEventStream(); }; @@ -125,7 +125,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); capturedPayload = payload; return createAssistantMessageEventStream(); }; @@ -158,7 +158,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); capturedPayload = payload; return createAssistantMessageEventStream(); }; diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts index 71af916ccac52..5be99b1fe8020 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts @@ -13,7 +13,7 @@ type StreamPayload = { function runOpenRouterPayload(payload: StreamPayload, modelId: string) { const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); return createAssistantMessageEventStream(); }; const agent = { streamFn: baseStreamFn }; diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 7054d765f81a4..ad1e1ef916a2d 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -222,7 +222,7 @@ function createGoogleThinkingPayloadWrapper( const onPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (model.api === "google-generative-ai") { sanitizeGoogleThinkingPayload({ payload, @@ -230,7 +230,7 @@ function createGoogleThinkingPayloadWrapper( thinkingLevel, }); } - onPayload?.(payload); + return onPayload?.(payload, payloadModel); }, }); }; @@ -258,12 +258,12 @@ function createZaiToolStreamWrapper( const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (payload && typeof payload === "object") { // Inject tool_stream: true for Z.AI API (payload as Record).tool_stream = true; } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; @@ -306,11 +306,11 @@ function createParallelToolCallsWrapper( const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (payload && typeof payload === "object") { (payload as Record).parallel_tool_calls = enabled; } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts index 3a757cea07367..f7262a667989f 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts @@ -21,8 +21,8 @@ type ToolStreamCase = { function runToolStreamCase(params: ToolStreamCase) { const payload: Record = { model: params.model.id, messages: [] }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); + const baseStreamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(payload, model); return {} as ReturnType; }; const agent = { streamFn: baseStreamFn }; diff --git a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts index 0cb17c6d49e38..384402ea7fd3d 100644 --- a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts @@ -53,14 +53,14 @@ export function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefi const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (payload && typeof payload === "object") { const payloadObj = payload as Record; if (payloadObj.thinking === "off") { payloadObj.thinking = null; } } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; @@ -89,7 +89,7 @@ export function createMoonshotThinkingWrapper( const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (payload && typeof payload === "object") { const payloadObj = payload as Record; let effectiveThinkingType = normalizeMoonshotThinkingType(payloadObj.thinking); @@ -106,7 +106,7 @@ export function createMoonshotThinkingWrapper( payloadObj.tool_choice = "auto"; } } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index fc72d9ca0fe54..63ac5134a464c 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -187,7 +187,7 @@ export function createOpenAIResponsesContextManagementWrapper( const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (payload && typeof payload === "object") { applyOpenAIResponsesPayloadOverrides({ payloadObj: payload as Record, @@ -197,7 +197,7 @@ export function createOpenAIResponsesContextManagementWrapper( compactThreshold, }); } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; @@ -219,14 +219,14 @@ export function createOpenAIServiceTierWrapper( const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { if (payload && typeof payload === "object") { const payloadObj = payload as Record; if (payloadObj.service_tier === undefined) { payloadObj.service_tier = serviceTier; } } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts index 5e8076ad49c46..bae540a48c305 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts @@ -73,7 +73,7 @@ export function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | unde const originalOnPayload = options?.onPayload; return underlying(model, context, { ...options, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { const messages = (payload as Record)?.messages; if (Array.isArray(messages)) { for (const msg of messages as Array<{ role?: string; content?: unknown }>) { @@ -92,7 +92,7 @@ export function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | unde } } } - originalOnPayload?.(payload); + return originalOnPayload?.(payload, payloadModel); }, }); }; @@ -111,9 +111,9 @@ export function createOpenRouterWrapper( ...OPENROUTER_APP_HEADERS, ...options?.headers, }, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { normalizeProxyReasoningPayload(payload, thinkingLevel); - onPayload?.(payload); + return onPayload?.(payload, payloadModel); }, }); }; @@ -136,9 +136,9 @@ export function createKilocodeWrapper( ...options?.headers, ...resolveKilocodeAppHeaders(), }, - onPayload: (payload) => { + onPayload: (payload, payloadModel) => { normalizeProxyReasoningPayload(payload, thinkingLevel); - onPayload?.(payload); + return onPayload?.(payload, payloadModel); }, }); }; diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 70bd3242f7c9c..9821adc0e0b41 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -520,7 +520,7 @@ describe("wrapOllamaCompatNumCtx", () => { let payloadSeen: Record | undefined; const baseFn = vi.fn((_model, _context, options) => { const payload: Record = { options: { temperature: 0.1 } }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloadSeen = payload; return {} as never; }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e480eb77797ba..b8dc464e51c6d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -227,17 +227,16 @@ export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: num return (model, context, options) => streamFn(model, context, { ...options, - onPayload: (payload: unknown) => { + onPayload: (payload: unknown, payloadModel) => { if (!payload || typeof payload !== "object") { - options?.onPayload?.(payload); - return; + return options?.onPayload?.(payload, payloadModel); } const payloadRecord = payload as Record; if (!payloadRecord.options || typeof payloadRecord.options !== "object") { payloadRecord.options = {}; } (payloadRecord.options as Record).num_ctx = numCtx; - options?.onPayload?.(payload); + return options?.onPayload?.(payload, payloadModel); }, }); } diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index 0ebd6c8c9d4cf..683354bf7a89f 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -1,5 +1,5 @@ -import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { loginOpenAICodex } from "@mariozechner/pi-ai"; +import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth"; +import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; @@ -53,7 +53,7 @@ export async function loginOpenAICodexOAuth(params: { const creds = await loginOpenAICodex({ onAuth: baseOnAuth, onPrompt, - onProgress: (msg) => spin.update(msg), + onProgress: (msg: string) => spin.update(msg), }); spin.stop("OpenAI OAuth complete"); return creds ?? null; diff --git a/src/providers/google-shared.ensures-function-call-comes-after-user-turn.test.ts b/src/providers/google-shared.ensures-function-call-comes-after-user-turn.test.ts index 888496fbd9646..9658bb791a9db 100644 --- a/src/providers/google-shared.ensures-function-call-comes-after-user-turn.test.ts +++ b/src/providers/google-shared.ensures-function-call-comes-after-user-turn.test.ts @@ -1,6 +1,6 @@ -import { convertMessages } from "@mariozechner/pi-ai/dist/providers/google-shared.js"; -import type { Context } from "@mariozechner/pi-ai/dist/types.js"; +import type { Context } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { convertMessages } from "../../node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js"; import { asRecord, expectConvertedRoles, diff --git a/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts b/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts index 95f7c155b585f..4cd1dabd4f149 100644 --- a/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts +++ b/src/providers/google-shared.preserves-parameters-type-is-missing.test.ts @@ -1,6 +1,9 @@ -import { convertMessages, convertTools } from "@mariozechner/pi-ai/dist/providers/google-shared.js"; -import type { Context, Tool } from "@mariozechner/pi-ai/dist/types.js"; +import type { Context, Tool } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { + convertMessages, + convertTools, +} from "../../node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js"; import { asRecord, expectConvertedRoles, diff --git a/src/providers/google-shared.test-helpers.ts b/src/providers/google-shared.test-helpers.ts index 6867f8796173b..548c33dadb187 100644 --- a/src/providers/google-shared.test-helpers.ts +++ b/src/providers/google-shared.test-helpers.ts @@ -1,4 +1,4 @@ -import type { Model } from "@mariozechner/pi-ai/dist/types.js"; +import type { Model } from "@mariozechner/pi-ai"; import { expect } from "vitest"; import { makeZeroUsageSnapshot } from "../agents/usage.js"; From 1d301f74a6a9262e5ec36e4725dffd4fbb49a3c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:13:32 +0000 Subject: [PATCH 241/311] refactor: extract telegram polling session --- src/telegram/monitor.ts | 303 +++----------------------------- src/telegram/polling-session.ts | 283 +++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 279 deletions(-) create mode 100644 src/telegram/polling-session.ts diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 29be5e05fea3f..ed1e1a8744a80 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -1,18 +1,15 @@ -import { type RunOptions, run } from "@grammyjs/runner"; +import type { RunOptions } from "@grammyjs/runner"; import { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { waitForAbortSignal } from "../infra/abort-signal.js"; -import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; -import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { createTelegramBot } from "./bot.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; +import { TelegramPollingSession } from "./polling-session.js"; import { makeProxyFetch } from "./proxy.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; import { startTelegramWebhook } from "./webhook.js"; @@ -55,21 +52,6 @@ export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions; - function normalizePersistedUpdateId(value: number | null): number | null { if (value === null) { return null; @@ -80,28 +62,6 @@ function normalizePersistedUpdateId(value: number | null): number | null { return value; } -const isGetUpdatesConflict = (err: unknown) => { - if (!err || typeof err !== "object") { - return false; - } - const typed = err as { - error_code?: number; - errorCode?: number; - description?: string; - method?: string; - message?: string; - }; - const errorCode = typed.error_code ?? typed.errorCode; - if (errorCode !== 409) { - return false; - } - const haystack = [typed.method, typed.description, typed.message] - .filter((value): value is string => typeof value === "string") - .join(" ") - .toLowerCase(); - return haystack.includes("getupdates"); -}; - /** Check if error is a Grammy HttpError (used to scope unhandled rejection handling) */ const isGrammyHttpError = (err: unknown): boolean => { if (!err || typeof err !== "object") { @@ -112,31 +72,26 @@ const isGrammyHttpError = (err: unknown): boolean => { export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const log = opts.runtime?.error ?? console.error; - let activeRunner: ReturnType | undefined; - let activeFetchAbort: AbortController | undefined; - let forceRestarted = false; + let pollingSession: TelegramPollingSession | undefined; - // Register handler for Grammy HttpError unhandled rejections. - // This catches network errors that escape the polling loop's try-catch - // (e.g., from setMyCommands during bot setup). - // We gate on isGrammyHttpError to avoid suppressing non-Telegram errors. const unregisterHandler = registerUnhandledRejectionHandler((err) => { const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" }); if (isGrammyHttpError(err) && isNetworkError) { log(`[telegram] Suppressed network error: ${formatErrorMessage(err)}`); - return true; // handled - don't crash + return true; } - // Network failures can surface outside the runner task promise and leave - // polling stuck; force-stop the active runner so the loop can recover. + + const activeRunner = pollingSession?.activeRunner; if (isNetworkError && activeRunner && activeRunner.isRunning()) { - forceRestarted = true; - activeFetchAbort?.abort(); + pollingSession?.markForceRestarted(); + pollingSession?.abortActiveFetch(); void activeRunner.stop().catch(() => {}); log( `[telegram] Restarting polling after unhandled network error: ${formatErrorMessage(err)}`, ); - return true; // handled + return true; } + return false; }); @@ -166,6 +121,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { `[telegram] Ignoring invalid persisted update offset (${String(persistedOffsetRaw)}); starting without offset confirmation.`, ); } + const persistUpdateId = async (updateId: number) => { const normalizedUpdateId = normalizePersistedUpdateId(updateId); if (normalizedUpdateId === null) { @@ -208,230 +164,19 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { return; } - // Use grammyjs/runner for concurrent update processing - let restartAttempts = 0; - let webhookCleared = false; - const runnerOptions = createTelegramRunnerOptions(cfg); - const waitBeforeRestart = async (buildLine: (delay: string) => string): Promise => { - restartAttempts += 1; - const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); - const delay = formatDurationPrecise(delayMs); - log(buildLine(delay)); - try { - await sleepWithAbort(delayMs, opts.abortSignal); - } catch (sleepErr) { - if (opts.abortSignal?.aborted) { - return false; - } - throw sleepErr; - } - return true; - }; - - const waitBeforeRetryOnRecoverableSetupError = async ( - err: unknown, - logPrefix: string, - ): Promise => { - if (opts.abortSignal?.aborted) { - return false; - } - if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) { - throw err; - } - return waitBeforeRestart( - (delay) => `${logPrefix}: ${formatErrorMessage(err)}; retrying in ${delay}.`, - ); - }; - - const createPollingBot = async ( - fetchAbortController: AbortController, - ): Promise => { - try { - return createTelegramBot({ - token, - runtime: opts.runtime, - proxyFetch, - config: cfg, - accountId: account.accountId, - fetchAbortSignal: fetchAbortController.signal, - updateOffset: { - lastUpdateId, - onUpdateId: persistUpdateId, - }, - }); - } catch (err) { - const shouldRetry = await waitBeforeRetryOnRecoverableSetupError( - err, - "Telegram setup network error", - ); - if (!shouldRetry) { - return undefined; - } - return undefined; - } - }; - - const ensureWebhookCleanup = async (bot: TelegramBot): Promise<"ready" | "retry" | "exit"> => { - if (webhookCleared) { - return "ready"; - } - try { - await withTelegramApiErrorLogging({ - operation: "deleteWebhook", - runtime: opts.runtime, - fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }), - }); - webhookCleared = true; - return "ready"; - } catch (err) { - const shouldRetry = await waitBeforeRetryOnRecoverableSetupError( - err, - "Telegram webhook cleanup failed", - ); - return shouldRetry ? "retry" : "exit"; - } - }; - - const confirmPersistedOffset = async (bot: TelegramBot): Promise => { - if (lastUpdateId === null || lastUpdateId >= Number.MAX_SAFE_INTEGER) { - return; - } - try { - await bot.api.getUpdates({ offset: lastUpdateId + 1, limit: 1, timeout: 0 }); - } catch { - // Non-fatal: runner middleware still skips duplicates via shouldSkipUpdate. - } - }; - - const runPollingCycle = async ( - bot: TelegramBot, - fetchAbortController: AbortController, - ): Promise<"continue" | "exit"> => { - // Confirm the persisted offset with Telegram so the runner (which starts - // at offset 0) does not re-fetch already-processed updates on restart. - await confirmPersistedOffset(bot); - - // Track getUpdates calls to detect polling stalls. - let lastGetUpdatesAt = Date.now(); - bot.api.config.use((prev, method, payload, signal) => { - if (method === "getUpdates") { - lastGetUpdatesAt = Date.now(); - } - return prev(method, payload, signal); - }); - - const runner = run(bot, runnerOptions); - activeRunner = runner; - let stopPromise: Promise | undefined; - let stalledRestart = false; - const stopRunner = () => { - fetchAbortController.abort(); - stopPromise ??= Promise.resolve(runner.stop()) - .then(() => undefined) - .catch(() => { - // Runner may already be stopped by abort/retry paths. - }); - return stopPromise; - }; - const stopBot = () => { - return Promise.resolve(bot.stop()) - .then(() => undefined) - .catch(() => { - // Bot may already be stopped by runner stop/abort paths. - }); - }; - const stopOnAbort = () => { - if (opts.abortSignal?.aborted) { - void stopRunner(); - } - }; - - // Watchdog: detect when getUpdates calls have stalled and force-restart. - const watchdog = setInterval(() => { - if (opts.abortSignal?.aborted) { - return; - } - const elapsed = Date.now() - lastGetUpdatesAt; - if (elapsed > POLL_STALL_THRESHOLD_MS && runner.isRunning()) { - stalledRestart = true; - log( - `[telegram] Polling stall detected (no getUpdates for ${formatDurationPrecise(elapsed)}); forcing restart.`, - ); - void stopRunner(); - } - }, POLL_WATCHDOG_INTERVAL_MS); - - opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); - try { - // runner.task() returns a promise that resolves when the runner stops - await runner.task(); - if (opts.abortSignal?.aborted) { - return "exit"; - } - const reason = stalledRestart - ? "polling stall detected" - : forceRestarted - ? "unhandled network error" - : "runner stopped (maxRetryTime exceeded or graceful stop)"; - forceRestarted = false; - const shouldRestart = await waitBeforeRestart( - (delay) => `Telegram polling runner stopped (${reason}); restarting in ${delay}.`, - ); - return shouldRestart ? "continue" : "exit"; - } catch (err) { - forceRestarted = false; - if (opts.abortSignal?.aborted) { - throw err; - } - const isConflict = isGetUpdatesConflict(err); - if (isConflict) { - webhookCleared = false; - } - const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); - if (!isConflict && !isRecoverable) { - throw err; - } - const reason = isConflict ? "getUpdates conflict" : "network error"; - const errMsg = formatErrorMessage(err); - const shouldRestart = await waitBeforeRestart( - (delay) => `Telegram ${reason}: ${errMsg}; retrying in ${delay}.`, - ); - return shouldRestart ? "continue" : "exit"; - } finally { - clearInterval(watchdog); - opts.abortSignal?.removeEventListener("abort", stopOnAbort); - await stopRunner(); - await stopBot(); - if (activeFetchAbort === fetchAbortController) { - activeFetchAbort = undefined; - } - } - }; - - while (!opts.abortSignal?.aborted) { - const fetchAbortController = new AbortController(); - activeFetchAbort = fetchAbortController; - const bot = await createPollingBot(fetchAbortController); - if (!bot) { - if (activeFetchAbort === fetchAbortController) { - activeFetchAbort = undefined; - } - continue; - } - - const cleanupState = await ensureWebhookCleanup(bot); - if (cleanupState === "retry") { - continue; - } - if (cleanupState === "exit") { - return; - } - - const state = await runPollingCycle(bot, fetchAbortController); - if (state === "exit") { - return; - } - } + pollingSession = new TelegramPollingSession({ + token, + config: cfg, + accountId: account.accountId, + runtime: opts.runtime, + proxyFetch, + abortSignal: opts.abortSignal, + runnerOptions: createTelegramRunnerOptions(cfg), + getLastUpdateId: () => lastUpdateId, + persistUpdateId, + log, + }); + await pollingSession.runUntilAbort(); } finally { unregisterHandler(); } diff --git a/src/telegram/polling-session.ts b/src/telegram/polling-session.ts new file mode 100644 index 0000000000000..784c8b2d75923 --- /dev/null +++ b/src/telegram/polling-session.ts @@ -0,0 +1,283 @@ +import { type RunOptions, run } from "@grammyjs/runner"; +import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { formatDurationPrecise } from "../infra/format-time/format-duration.ts"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { createTelegramBot } from "./bot.js"; +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; + +const TELEGRAM_POLL_RESTART_POLICY = { + initialMs: 2000, + maxMs: 30_000, + factor: 1.8, + jitter: 0.25, +}; + +const POLL_STALL_THRESHOLD_MS = 90_000; +const POLL_WATCHDOG_INTERVAL_MS = 30_000; + +type TelegramBot = ReturnType; + +type TelegramPollingSessionOpts = { + token: string; + config: Parameters[0]["config"]; + accountId: string; + runtime: Parameters[0]["runtime"]; + proxyFetch: Parameters[0]["proxyFetch"]; + abortSignal?: AbortSignal; + runnerOptions: RunOptions; + getLastUpdateId: () => number | null; + persistUpdateId: (updateId: number) => Promise; + log: (line: string) => void; +}; + +export class TelegramPollingSession { + #restartAttempts = 0; + #webhookCleared = false; + #forceRestarted = false; + #activeRunner: ReturnType | undefined; + #activeFetchAbort: AbortController | undefined; + + constructor(private readonly opts: TelegramPollingSessionOpts) {} + + get activeRunner() { + return this.#activeRunner; + } + + markForceRestarted() { + this.#forceRestarted = true; + } + + abortActiveFetch() { + this.#activeFetchAbort?.abort(); + } + + async runUntilAbort(): Promise { + while (!this.opts.abortSignal?.aborted) { + const bot = await this.#createPollingBot(); + if (!bot) { + continue; + } + + const cleanupState = await this.#ensureWebhookCleanup(bot); + if (cleanupState === "retry") { + continue; + } + if (cleanupState === "exit") { + return; + } + + const state = await this.#runPollingCycle(bot); + if (state === "exit") { + return; + } + } + } + + async #waitBeforeRestart(buildLine: (delay: string) => string): Promise { + this.#restartAttempts += 1; + const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, this.#restartAttempts); + const delay = formatDurationPrecise(delayMs); + this.opts.log(buildLine(delay)); + try { + await sleepWithAbort(delayMs, this.opts.abortSignal); + } catch (sleepErr) { + if (this.opts.abortSignal?.aborted) { + return false; + } + throw sleepErr; + } + return true; + } + + async #waitBeforeRetryOnRecoverableSetupError(err: unknown, logPrefix: string): Promise { + if (this.opts.abortSignal?.aborted) { + return false; + } + if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) { + throw err; + } + return this.#waitBeforeRestart( + (delay) => `${logPrefix}: ${formatErrorMessage(err)}; retrying in ${delay}.`, + ); + } + + async #createPollingBot(): Promise { + const fetchAbortController = new AbortController(); + this.#activeFetchAbort = fetchAbortController; + try { + return createTelegramBot({ + token: this.opts.token, + runtime: this.opts.runtime, + proxyFetch: this.opts.proxyFetch, + config: this.opts.config, + accountId: this.opts.accountId, + fetchAbortSignal: fetchAbortController.signal, + updateOffset: { + lastUpdateId: this.opts.getLastUpdateId(), + onUpdateId: this.opts.persistUpdateId, + }, + }); + } catch (err) { + await this.#waitBeforeRetryOnRecoverableSetupError(err, "Telegram setup network error"); + if (this.#activeFetchAbort === fetchAbortController) { + this.#activeFetchAbort = undefined; + } + return undefined; + } + } + + async #ensureWebhookCleanup(bot: TelegramBot): Promise<"ready" | "retry" | "exit"> { + if (this.#webhookCleared) { + return "ready"; + } + try { + await withTelegramApiErrorLogging({ + operation: "deleteWebhook", + runtime: this.opts.runtime, + fn: () => bot.api.deleteWebhook({ drop_pending_updates: false }), + }); + this.#webhookCleared = true; + return "ready"; + } catch (err) { + const shouldRetry = await this.#waitBeforeRetryOnRecoverableSetupError( + err, + "Telegram webhook cleanup failed", + ); + return shouldRetry ? "retry" : "exit"; + } + } + + async #confirmPersistedOffset(bot: TelegramBot): Promise { + const lastUpdateId = this.opts.getLastUpdateId(); + if (lastUpdateId === null || lastUpdateId >= Number.MAX_SAFE_INTEGER) { + return; + } + try { + await bot.api.getUpdates({ offset: lastUpdateId + 1, limit: 1, timeout: 0 }); + } catch { + // Non-fatal: runner middleware still skips duplicates via shouldSkipUpdate. + } + } + + async #runPollingCycle(bot: TelegramBot): Promise<"continue" | "exit"> { + await this.#confirmPersistedOffset(bot); + + let lastGetUpdatesAt = Date.now(); + bot.api.config.use((prev, method, payload, signal) => { + if (method === "getUpdates") { + lastGetUpdatesAt = Date.now(); + } + return prev(method, payload, signal); + }); + + const runner = run(bot, this.opts.runnerOptions); + this.#activeRunner = runner; + const fetchAbortController = this.#activeFetchAbort; + let stopPromise: Promise | undefined; + let stalledRestart = false; + const stopRunner = () => { + fetchAbortController?.abort(); + stopPromise ??= Promise.resolve(runner.stop()) + .then(() => undefined) + .catch(() => { + // Runner may already be stopped by abort/retry paths. + }); + return stopPromise; + }; + const stopBot = () => { + return Promise.resolve(bot.stop()) + .then(() => undefined) + .catch(() => { + // Bot may already be stopped by runner stop/abort paths. + }); + }; + const stopOnAbort = () => { + if (this.opts.abortSignal?.aborted) { + void stopRunner(); + } + }; + + const watchdog = setInterval(() => { + if (this.opts.abortSignal?.aborted) { + return; + } + const elapsed = Date.now() - lastGetUpdatesAt; + if (elapsed > POLL_STALL_THRESHOLD_MS && runner.isRunning()) { + stalledRestart = true; + this.opts.log( + `[telegram] Polling stall detected (no getUpdates for ${formatDurationPrecise(elapsed)}); forcing restart.`, + ); + void stopRunner(); + } + }, POLL_WATCHDOG_INTERVAL_MS); + + this.opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); + try { + await runner.task(); + if (this.opts.abortSignal?.aborted) { + return "exit"; + } + const reason = stalledRestart + ? "polling stall detected" + : this.#forceRestarted + ? "unhandled network error" + : "runner stopped (maxRetryTime exceeded or graceful stop)"; + this.#forceRestarted = false; + const shouldRestart = await this.#waitBeforeRestart( + (delay) => `Telegram polling runner stopped (${reason}); restarting in ${delay}.`, + ); + return shouldRestart ? "continue" : "exit"; + } catch (err) { + this.#forceRestarted = false; + if (this.opts.abortSignal?.aborted) { + throw err; + } + const isConflict = isGetUpdatesConflict(err); + if (isConflict) { + this.#webhookCleared = false; + } + const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); + if (!isConflict && !isRecoverable) { + throw err; + } + const reason = isConflict ? "getUpdates conflict" : "network error"; + const errMsg = formatErrorMessage(err); + const shouldRestart = await this.#waitBeforeRestart( + (delay) => `Telegram ${reason}: ${errMsg}; retrying in ${delay}.`, + ); + return shouldRestart ? "continue" : "exit"; + } finally { + clearInterval(watchdog); + this.opts.abortSignal?.removeEventListener("abort", stopOnAbort); + await stopRunner(); + await stopBot(); + this.#activeRunner = undefined; + if (this.#activeFetchAbort === fetchAbortController) { + this.#activeFetchAbort = undefined; + } + } + } +} + +const isGetUpdatesConflict = (err: unknown) => { + if (!err || typeof err !== "object") { + return false; + } + const typed = err as { + error_code?: number; + errorCode?: number; + description?: string; + method?: string; + message?: string; + }; + const errorCode = typed.error_code ?? typed.errorCode; + if (errorCode !== 409) { + return false; + } + const haystack = [typed.method, typed.description, typed.message] + .filter((value): value is string => typeof value === "string") + .join(" ") + .toLowerCase(); + return haystack.includes("getupdates"); +}; From e86b38f09d853b4b8a30c14b80305fcbd1e97935 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:14:34 +0000 Subject: [PATCH 242/311] refactor: split cron startup catch-up flow --- src/cron/service/timer.ts | 125 ++++++++++++++++++++++++-------------- 1 file changed, 79 insertions(+), 46 deletions(-) diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 08b4b6be20603..f82290006b41a 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -53,6 +53,16 @@ type TimedCronRunOutcome = CronRunOutcome & endedAt: number; }; +type StartupCatchupCandidate = { + jobId: string; + job: CronJob; +}; + +type StartupCatchupPlan = { + candidates: StartupCatchupCandidate[]; + deferredJobIds: string[]; +}; + export async function executeJobCoreWithTimeout( state: CronServiceState, job: CronJob, @@ -832,31 +842,37 @@ export async function runMissedJobs( state: CronServiceState, opts?: { skipJobIds?: ReadonlySet }, ) { - const staggerMs = Math.max(0, state.deps.missedJobStaggerMs ?? DEFAULT_MISSED_JOB_STAGGER_MS); + const plan = await planStartupCatchup(state, opts); + if (plan.candidates.length === 0 && plan.deferredJobIds.length === 0) { + return; + } + + const outcomes = await executeStartupCatchupPlan(state, plan); + await applyStartupCatchupOutcomes(state, plan, outcomes); +} + +async function planStartupCatchup( + state: CronServiceState, + opts?: { skipJobIds?: ReadonlySet }, +): Promise { const maxImmediate = Math.max( 0, state.deps.maxMissedJobsPerRestart ?? DEFAULT_MAX_MISSED_JOBS_PER_RESTART, ); - const selection = await locked(state, async () => { + return locked(state, async () => { await ensureLoaded(state, { skipRecompute: true }); if (!state.store) { - return { - deferredJobIds: [] as string[], - startupCandidates: [] as Array<{ jobId: string; job: CronJob }>, - }; + return { candidates: [], deferredJobIds: [] }; } + const now = state.deps.nowMs(); - const skipJobIds = opts?.skipJobIds; const missed = collectRunnableJobs(state, now, { - skipJobIds, + skipJobIds: opts?.skipJobIds, skipAtIfAlreadyRan: true, allowCronMissedRunByLastRun: true, }); if (missed.length === 0) { - return { - deferredJobIds: [] as string[], - startupCandidates: [] as Array<{ jobId: string; job: CronJob }>, - }; + return { candidates: [], deferredJobIds: [] }; } const sorted = missed.toSorted( (a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0), @@ -884,47 +900,64 @@ export async function runMissedJobs( job.state.lastError = undefined; } await persist(state); + return { + candidates: startupCandidates.map((job) => ({ jobId: job.id, job })), deferredJobIds: deferred.map((job) => job.id), - startupCandidates: startupCandidates.map((job) => ({ jobId: job.id, job })), }; }); +} - if (selection.startupCandidates.length === 0 && selection.deferredJobIds.length === 0) { - return; +async function executeStartupCatchupPlan( + state: CronServiceState, + plan: StartupCatchupPlan, +): Promise { + const outcomes: TimedCronRunOutcome[] = []; + for (const candidate of plan.candidates) { + outcomes.push(await runStartupCatchupCandidate(state, candidate)); } + return outcomes; +} - const outcomes: Array = []; - for (const candidate of selection.startupCandidates) { - const startedAt = state.deps.nowMs(); - emit(state, { jobId: candidate.job.id, action: "started", runAtMs: startedAt }); - try { - const result = await executeJobCoreWithTimeout(state, candidate.job); - outcomes.push({ - jobId: candidate.jobId, - status: result.status, - error: result.error, - summary: result.summary, - delivered: result.delivered, - sessionId: result.sessionId, - sessionKey: result.sessionKey, - model: result.model, - provider: result.provider, - usage: result.usage, - startedAt, - endedAt: state.deps.nowMs(), - }); - } catch (err) { - outcomes.push({ - jobId: candidate.jobId, - status: "error", - error: String(err), - startedAt, - endedAt: state.deps.nowMs(), - }); - } +async function runStartupCatchupCandidate( + state: CronServiceState, + candidate: StartupCatchupCandidate, +): Promise { + const startedAt = state.deps.nowMs(); + emit(state, { jobId: candidate.job.id, action: "started", runAtMs: startedAt }); + try { + const result = await executeJobCoreWithTimeout(state, candidate.job); + return { + jobId: candidate.jobId, + status: result.status, + error: result.error, + summary: result.summary, + delivered: result.delivered, + sessionId: result.sessionId, + sessionKey: result.sessionKey, + model: result.model, + provider: result.provider, + usage: result.usage, + startedAt, + endedAt: state.deps.nowMs(), + }; + } catch (err) { + return { + jobId: candidate.jobId, + status: "error", + error: String(err), + startedAt, + endedAt: state.deps.nowMs(), + }; } +} +async function applyStartupCatchupOutcomes( + state: CronServiceState, + plan: StartupCatchupPlan, + outcomes: TimedCronRunOutcome[], +): Promise { + const staggerMs = Math.max(0, state.deps.missedJobStaggerMs ?? DEFAULT_MISSED_JOB_STAGGER_MS); await locked(state, async () => { await ensureLoaded(state, { forceReload: true, skipRecompute: true }); if (!state.store) { @@ -935,10 +968,10 @@ export async function runMissedJobs( applyOutcomeToStoredJob(state, result); } - if (selection.deferredJobIds.length > 0) { + if (plan.deferredJobIds.length > 0) { const baseNow = state.deps.nowMs(); let offset = staggerMs; - for (const jobId of selection.deferredJobIds) { + for (const jobId of plan.deferredJobIds) { const job = state.store.jobs.find((entry) => entry.id === jobId); if (!job || !job.enabled) { continue; From 17599a8ea21367b9df1765d6c4768edbdecc440a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:14:58 +0000 Subject: [PATCH 243/311] refactor: flatten supervisor marker hints --- src/infra/supervisor-markers.ts | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/src/infra/supervisor-markers.ts b/src/infra/supervisor-markers.ts index 5b714735724a9..cbe8d4807bfe5 100644 --- a/src/infra/supervisor-markers.ts +++ b/src/infra/supervisor-markers.ts @@ -1,23 +1,13 @@ -const LAUNCHD_SUPERVISOR_HINT_ENV_VARS = [ - "LAUNCH_JOB_LABEL", - "LAUNCH_JOB_NAME", - "XPC_SERVICE_NAME", - "OPENCLAW_LAUNCHD_LABEL", -] as const; - -const SYSTEMD_SUPERVISOR_HINT_ENV_VARS = [ - "OPENCLAW_SYSTEMD_UNIT", - "INVOCATION_ID", - "SYSTEMD_EXEC_PID", - "JOURNAL_STREAM", -] as const; - -const WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS = ["OPENCLAW_WINDOWS_TASK_NAME"] as const; +const SUPERVISOR_HINTS = { + launchd: ["LAUNCH_JOB_LABEL", "LAUNCH_JOB_NAME", "XPC_SERVICE_NAME", "OPENCLAW_LAUNCHD_LABEL"], + systemd: ["OPENCLAW_SYSTEMD_UNIT", "INVOCATION_ID", "SYSTEMD_EXEC_PID", "JOURNAL_STREAM"], + schtasks: ["OPENCLAW_WINDOWS_TASK_NAME"], +} as const; export const SUPERVISOR_HINT_ENV_VARS = [ - ...LAUNCHD_SUPERVISOR_HINT_ENV_VARS, - ...SYSTEMD_SUPERVISOR_HINT_ENV_VARS, - ...WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS, + ...SUPERVISOR_HINTS.launchd, + ...SUPERVISOR_HINTS.systemd, + ...SUPERVISOR_HINTS.schtasks, "OPENCLAW_SERVICE_MARKER", "OPENCLAW_SERVICE_KIND", ] as const; @@ -36,13 +26,13 @@ export function detectRespawnSupervisor( platform: NodeJS.Platform = process.platform, ): RespawnSupervisor | null { if (platform === "darwin") { - return hasAnyHint(env, LAUNCHD_SUPERVISOR_HINT_ENV_VARS) ? "launchd" : null; + return hasAnyHint(env, SUPERVISOR_HINTS.launchd) ? "launchd" : null; } if (platform === "linux") { - return hasAnyHint(env, SYSTEMD_SUPERVISOR_HINT_ENV_VARS) ? "systemd" : null; + return hasAnyHint(env, SUPERVISOR_HINTS.systemd) ? "systemd" : null; } if (platform === "win32") { - if (hasAnyHint(env, WINDOWS_TASK_SUPERVISOR_HINT_ENV_VARS)) { + if (hasAnyHint(env, SUPERVISOR_HINTS.schtasks)) { return "schtasks"; } const marker = env.OPENCLAW_SERVICE_MARKER?.trim(); From f82931ba8bafbab50403b7548b66a57157241292 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:24:23 +0000 Subject: [PATCH 244/311] docs: reorder 2026.3.8 changelog by impact --- CHANGELOG.md | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 208822084ee0b..c9c445f83372a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,57 +15,57 @@ Docs: https://docs.openclaw.ai ### Changes -- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code. +- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. +- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. +- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. -- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. - CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. -- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. -- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. -- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. - CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras. - ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky. - Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku. +- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. +- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code. ### Breaking ### Fixes -- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. -- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092) -- Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu. - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. -- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. -- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. +- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. +- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus. +- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. +- Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu. +- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092) +- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending. +- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord. +- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn. +- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. +- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. +- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. +- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. +- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. +- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline. +- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii. +- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. +- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. +- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one. - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. -- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. -- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline. -- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150. -- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni. -- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander. -- Browser/extension relay: add `browser.relayBindHost` so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn. - Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock. - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. -- macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. - Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. -- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc. - Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232) -- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending. -- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord. - Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark. -- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii. - Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung. -- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. -- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko. -- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus. +- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. - Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis. - Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468. - Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat. From e2a1a4a3db7b70417e8e4d41c98a8ea24d353ff4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:24:54 +0000 Subject: [PATCH 245/311] build: sync pnpm lockfile --- pnpm-lock.yaml | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e57a623a315a..3ae9ea71e0c63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: 0.1.7-alpha.2 version: 0.1.7-alpha.2 tar: - specifier: 7.5.11 - version: 7.5.11 + specifier: 7.5.10 + version: 7.5.10 tslog: specifier: ^4.10.2 version: 4.10.2 @@ -6300,10 +6300,6 @@ packages: resolution: {integrity: sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==} engines: {node: '>=18'} - tar@7.5.11: - resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} - engines: {node: '>=18'} - text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -13918,14 +13914,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tar@7.5.11: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - text-decoder@1.2.7: dependencies: b4a: 1.8.0 From 9631f4665cea3919086100c30b559dca5a500b15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:31:35 +0000 Subject: [PATCH 246/311] chore: refresh secrets baseline --- .secrets.baseline | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 4f591aa13ef05..871217bc3bc3a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -12933,14 +12933,14 @@ "filename": "src/telegram/monitor.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 450 + "line_number": 497 }, { "type": "Secret Keyword", "filename": "src/telegram/monitor.test.ts", "hashed_secret": "5934c4d4a4fa5d66ddb3d3fc0bba84996c17a5b7", "is_verified": false, - "line_number": 641 + "line_number": 688 } ], "src/telegram/webhook.test.ts": [ @@ -13035,5 +13035,5 @@ } ] }, - "generated_at": "2026-03-09T01:11:58Z" + "generated_at": "2026-03-09T06:30:58Z" } From 2d55ad05f397d9a20df0f677498f60094a59b749 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:34:48 +0000 Subject: [PATCH 247/311] docs: move 2026.3.8 entries back to unreleased --- CHANGELOG.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c445f83372a..b930ee7514a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,6 @@ Docs: https://docs.openclaw.ai ## Unreleased -### Fixes - -- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. -- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. -- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. -- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. - -## 2026.3.8 - ### Changes - CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. @@ -72,6 +63,10 @@ Docs: https://docs.openclaw.ai - Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale `getUpdates` long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland. - Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae. - Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference. +- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent. +- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent. +- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. +- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. ## 2026.3.7 From 8d2d6db9ada1dc1afe482cf7e52d4526c2cf5d6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:45:02 +0000 Subject: [PATCH 248/311] test: fix Node 24+ test runner and subagent registry mocks --- scripts/test-parallel.mjs | 6 +++--- src/agents/subagent-registry.archive.e2e.test.ts | 14 +++++++++----- ...gent-registry.lifecycle-retry-grace.e2e.test.ts | 10 +++++++--- src/agents/subagent-registry.nested.e2e.test.ts | 14 +++++++++----- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 67129a24aea8e..ca7636394bb70 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -104,11 +104,11 @@ const hostMemoryGiB = Math.floor(os.totalmem() / 1024 ** 3); const highMemLocalHost = !isCI && hostMemoryGiB >= 96; const lowMemLocalHost = !isCI && hostMemoryGiB < 64; const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10); -// vmForks is a big win for transform/import heavy suites, but Node 24 had -// regressions with Vitest's vm runtime in this repo, and low-memory local hosts +// vmForks is a big win for transform/import heavy suites, but Node 24+ +// regressed with Vitest's vm runtime in this repo, and low-memory local hosts // are more likely to hit per-worker V8 heap ceilings. Keep it opt-out via // OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1. -const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor !== 24 : true; +const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor < 24 : true; const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" || (process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks && !lowMemLocalHost); diff --git a/src/agents/subagent-registry.archive.e2e.test.ts b/src/agents/subagent-registry.archive.e2e.test.ts index 20148db527a62..8cd2a9b634ee4 100644 --- a/src/agents/subagent-registry.archive.e2e.test.ts +++ b/src/agents/subagent-registry.archive.e2e.test.ts @@ -17,11 +17,15 @@ vi.mock("../infra/agent-events.js", () => ({ onAgentEvent: vi.fn((_handler: unknown) => noop), })); -vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ - agents: { defaults: { subagents: { archiveAfterMinutes: 60 } } }, - })), -})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 60 } } }, + })), + }; +}); vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: vi.fn(async () => true), diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts index 9373ee5de6462..570c51d3131ec 100644 --- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -49,9 +49,13 @@ vi.mock("../infra/agent-events.js", () => ({ onAgentEvent: onAgentEventMock, })); -vi.mock("../config/config.js", () => ({ - loadConfig: loadConfigMock, -})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: loadConfigMock, + }; +}); vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: announceSpy, diff --git a/src/agents/subagent-registry.nested.e2e.test.ts b/src/agents/subagent-registry.nested.e2e.test.ts index 30e447149c237..061487059862d 100644 --- a/src/agents/subagent-registry.nested.e2e.test.ts +++ b/src/agents/subagent-registry.nested.e2e.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; -vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ - agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, - })), -})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: vi.fn(() => ({ + agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, + })), + }; +}); vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: vi.fn(async () => true), From 912aa8744aa83cc1fcead8058645cd06aa4bfe89 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:50:52 +0000 Subject: [PATCH 249/311] test: fix Windows fake runtime bin fixtures --- src/node-host/invoke-system-run-plan.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 8b10bceb2c074..019eb7b77b9dc 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -69,9 +69,16 @@ function withFakeRuntimeBin(params: { binName: string; run: () => T }): T { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.binName}-bin-`)); const binDir = path.join(tmp, "bin"); fs.mkdirSync(binDir, { recursive: true }); - const runtimePath = path.join(binDir, params.binName); - fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - fs.chmodSync(runtimePath, 0o755); + const runtimePath = + process.platform === "win32" + ? path.join(binDir, `${params.binName}.cmd`) + : path.join(binDir, params.binName); + const runtimeBody = + process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; + fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); + if (process.platform !== "win32") { + fs.chmodSync(runtimePath, 0o755); + } const oldPath = process.env.PATH; process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; try { From 66c581c64c1ce35a6ca961e995bbe398987fd398 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 07:01:42 +0000 Subject: [PATCH 250/311] fix: normalize windows runtime shim executables --- src/infra/exec-wrapper-resolution.test.ts | 16 ++++++++++++++++ src/infra/exec-wrapper-resolution.ts | 17 +++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 src/infra/exec-wrapper-resolution.test.ts diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts new file mode 100644 index 0000000000000..b271c97ee8dda --- /dev/null +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { normalizeExecutableToken } from "./exec-wrapper-resolution.js"; + +describe("normalizeExecutableToken", () => { + test("strips common windows executable suffixes", () => { + expect(normalizeExecutableToken("bun.cmd")).toBe("bun"); + expect(normalizeExecutableToken("deno.bat")).toBe("deno"); + expect(normalizeExecutableToken("pwsh.com")).toBe("pwsh"); + expect(normalizeExecutableToken("cmd.exe")).toBe("cmd"); + }); + + test("normalizes path-qualified windows shims", () => { + expect(normalizeExecutableToken("C:\\tools\\bun.cmd")).toBe("bun"); + expect(normalizeExecutableToken("/tmp/deno.exe")).toBe("deno"); + }); +}); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 006a0a6561255..0cb423a11b3e8 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -7,7 +7,7 @@ import { export const MAX_DISPATCH_WRAPPER_DEPTH = 4; -const WINDOWS_EXE_SUFFIX = ".exe"; +const WINDOWS_EXECUTABLE_SUFFIXES = [".exe", ".cmd", ".bat", ".com"] as const; const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; @@ -31,13 +31,18 @@ function withWindowsExeAliases(names: readonly string[]): string[] { const expanded = new Set(); for (const name of names) { expanded.add(name); - expanded.add(`${name}${WINDOWS_EXE_SUFFIX}`); + expanded.add(`${name}.exe`); } return Array.from(expanded); } -function stripWindowsExeSuffix(value: string): string { - return value.endsWith(WINDOWS_EXE_SUFFIX) ? value.slice(0, -WINDOWS_EXE_SUFFIX.length) : value; +function stripWindowsExecutableSuffix(value: string): string { + for (const suffix of WINDOWS_EXECUTABLE_SUFFIXES) { + if (value.endsWith(suffix)) { + return value.slice(0, -suffix.length); + } + } + return value; } export const POSIX_SHELL_WRAPPERS = new Set(POSIX_SHELL_WRAPPER_NAMES); @@ -115,7 +120,7 @@ export function basenameLower(token: string): string { } export function normalizeExecutableToken(token: string): string { - return stripWindowsExeSuffix(basenameLower(token)); + return stripWindowsExecutableSuffix(basenameLower(token)); } export function isDispatchWrapperExecutable(token: string): boolean { @@ -132,7 +137,7 @@ function normalizeRawCommand(rawCommand?: string | null): string | null { } function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { - const canonicalBase = stripWindowsExeSuffix(baseExecutable); + const canonicalBase = stripWindowsExecutableSuffix(baseExecutable); for (const spec of SHELL_WRAPPER_SPECS) { if (spec.names.has(canonicalBase)) { return spec; From 5fca4c0de0c1f7de278d7d120b837c23fca6c984 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 06:59:40 +0000 Subject: [PATCH 251/311] chore: prepare 2026.3.8-beta.1 release --- CHANGELOG.md | 10 ++++++++-- extensions/acpx/package.json | 2 +- extensions/bluebubbles/package.json | 2 +- extensions/copilot-proxy/package.json | 2 +- extensions/diagnostics-otel/package.json | 2 +- extensions/diffs/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 +- package.json | 2 +- 42 files changed, 84 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b930ee7514a6c..bc13a8625228a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Docs: https://docs.openclaw.ai ### Changes +### Breaking + +### Fixes + +## 2026.3.8-beta.1 + +### Changes + - CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs. - macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek. - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. @@ -18,8 +26,6 @@ Docs: https://docs.openclaw.ai - Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao. - Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code. -### Breaking - ### Fixes - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 6c1231c41d591..ae560c8e9affc 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/acpx", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index fcd1c8f8a38fe..80d314cee3842 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 52ffbafe2cc84..9d72a35306998 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "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 67f06348554bd..27dcdda7fb1ed 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 581777e2bd0ba..5df160b125a24 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index d37f86446265f..607a0a8dd3b5e 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 41279e4811134..7fcd26f67fc83 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "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 9643ee78ee674..2e5eca8365a47 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.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 1a7a876b4ef30..3fbfb30a4729d 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 38d4262befedb..1a350de21463b 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 9cbdee3a92323..44040e8428b19 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw IRC channel plugin", "type": "module", "dependencies": { diff --git a/extensions/line/package.json b/extensions/line/package.json index 1a90e0a008082..227da7e8f8453 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 537b4aa6d7fa0..1cdcdf7cb83f2 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index f9e0b458d5e5d..67495efb3006c 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "dependencies": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 75241a274e8e6..8a8bbcb87f419 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 1b769b65011fd..11fa647936a87 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 4042b01101f8d..1b823e4d08bb8 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Mattermost channel plugin", "type": "module", "dependencies": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index d7a551f49098e..ce7521de44a8e 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "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 0c282a85edcf5..067b32446eb2f 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "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 6e6c9fc3cb8d9..b588a963474fa 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.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 4f23bc09499be..4ca638b8f7c73 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 0415203ff0def..c6b4106ee056f 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 5a8193b96212e..bef40590adc00 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index b05a75d233a37..7daad7c06f956 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 389d7b210ffa3..c6fdf0056d4b1 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "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 956472bf65c67..d323b6ca9e655 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "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 f51c86f614169..88fe04ac1e48a 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index a76b301f545cc..cf62b6ce62758 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 4215e36650e68..ff83998b45632 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 9fc1c662d4627..eba1967b064fb 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 9fe4d84353a17..159daa5e69d3a 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index ebc77095f9d98..3ef565f84a9f8 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 21b95602ec7b7..04bdf10977baa 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 0ac97782967f4..6bd0d2aadebc6 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 82bdf122a525d..c01c2194cb471 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 636805ab1bd9d..b3cf0a655c387 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index b964ba2f7b2a0..647dc8c3f45a0 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 1dab87a713c08..30de1e0ea0351 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index ed4be6a2c9a59..15ecfd974cc8f 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.8-beta.1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 217653508dbec..9f352c143614f 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { diff --git a/package.json b/package.json index 5f6d8930124cc..236ecbb79a292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.8", + "version": "2026.3.8-beta.1", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 250d3c949eb7b4c4689eb294477536628de509ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 07:20:08 +0000 Subject: [PATCH 252/311] chore: update appcast for 2026.3.8-beta.1 --- appcast.xml | 213 ++++++++++++++++++---------------------------------- 1 file changed, 74 insertions(+), 139 deletions(-) diff --git a/appcast.xml b/appcast.xml index 7d0a1988b39cc..4bceb205614a3 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,80 @@ OpenClaw + + 2026.3.8-beta.1 + Mon, 09 Mar 2026 07:19:57 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 2026030801 + 2026.3.8-beta.1 + 15.0 + OpenClaw 2026.3.8-beta.1 +

Changes

+
    +
  • CLI/backup: add openclaw backup create and openclaw backup verify for local state archives, including --only-config, --no-include-workspace, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
  • +
  • macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext gateway.remote.token config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
  • +
  • Talk mode: add top-level talk.silenceTimeoutMs config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
  • +
  • TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit agent: session targets. (#39591) thanks @arceus77-7.
  • +
  • Tools/Brave web search: add opt-in tools.web.search.brave.mode: "llm-context" so web_search can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
  • +
  • CLI/install: include the short git commit hash in openclaw --version output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
  • +
  • CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
  • +
  • ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (openclaw acp --provenance off|meta|meta+receipt) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.
  • +
  • Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.
  • +
  • Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
  • +
  • Extensions/ACPX tests: move the shared runtime fixture helper from src/runtime-internals/ to src/test-utils/ so the test-only helper no longer looks like shipped runtime code.
  • +
+

Fixes

+
    +
  • macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
  • +
  • Android/Play distribution: remove self-update, background location, screen.record, and background mic capture from the Android app, narrow the foreground service to dataSync only, and clean up the legacy location.enabledMode=always preference migration. (#39660) Thanks @obviyus.
  • +
  • Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both agent:main:main and agent:main:telegram:direct: resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
  • +
  • Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report delivered: true when no message actually reached Telegram. (#40575) thanks @obviyus.
  • +
  • Matrix/DM routing: add safer fallback detection for broken m.direct homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.
  • +
  • Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.
  • +
  • Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
  • +
  • Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
  • +
  • Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.
  • +
  • Browser/extension relay: add browser.relayBindHost so the Chrome relay can bind to an explicit non-loopback address for WSL2 and other cross-namespace setups, while preserving loopback-only defaults. (#39364) Thanks @mvanhorn.
  • +
  • Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for /json/* tab operations so local ws:// / wss:// profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
  • +
  • Browser/CDP: rewrite wildcard ws://0.0.0.0 and ws://[::] debugger URLs from remote /json/version responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
  • +
  • Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with tab not found, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
  • +
  • macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved .ts.net and Tailscale Serve gateways, and set TERM=dumb for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman.
  • +
  • TUI/theme: detect light terminal backgrounds via COLORFGBG and pick a WCAG AA-compliant light palette, with OPENCLAW_THEME=light|dark override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
  • +
  • Agents/openai-codex: normalize gpt-5.4 fallback transport back to openai-codex-responses on chatgpt.com/backend-api when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
  • +
  • Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for openai-codex/gpt-5.4 instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.
  • +
  • Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy OPENROUTER_API_KEY, sk-or-..., and explicit perplexity.baseUrl / model setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
  • +
  • Agents/failover: detect Amazon Bedrock Too many tokens per day quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window too many tokens per request errors out of the rate-limit lane. (#39377) Thanks @gambletan.
  • +
  • Mattermost replies: keep root_id pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
  • +
  • Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.
  • +
  • macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared inout visibility mutation from OverlayPanelFactory.present, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
  • +
  • macOS Talk Mode: set the speech recognition request taskHint to .dictation for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
  • +
  • macOS release packaging: default scripts/package-mac-app.sh to universal binaries for BUILD_CONFIG=release, and clarify that scripts/package-mac-dist.sh already produces the release zip + DMG. (#33891) Thanks @cgdusek.
  • +
  • Hooks/session-memory: keep /new and /reset memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
  • +
  • Sessions/model switch: clear stale cached contextTokens when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
  • +
  • ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
  • +
  • Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
  • +
  • Context engine registry/bundled builds: share the registry state through a globalThis singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman.
  • +
  • Podman/setup: fix cannot chdir: Permission denied in run_as_user when setup-podman.sh is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to /tmp with / fallback. (#39435) Thanks @langdon and @jlcbk.
  • +
  • Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add :Z relabel to bind mounts in run-openclaw-podman.sh and the Quadlet template, fixing EACCES on Fedora/RHEL hosts. Supports OPENCLAW_BIND_MOUNT_OPTIONS override. (#39449) Thanks @langdon and @githubbzxs.
  • +
  • Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
  • +
  • Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
  • +
  • Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
  • +
  • Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
  • +
  • Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.
  • +
  • Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.
  • +
  • Gateway/launchd respawn detection: treat XPC_SERVICE_NAME as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.
  • +
  • Telegram/poll restart cleanup: abort the in-flight Telegram API fetch when shutdown or forced polling restarts stop a runner, preventing stale getUpdates long polls from colliding with the replacement runner. Landed from contributor PR #23950 by @Gkinthecodeland. Thanks @Gkinthecodeland.
  • +
  • Cron/restart catch-up staggering: limit immediate missed-job replay on startup and reschedule the deferred remainder from the post-catchup clock so restart bursts do not starve the gateway or silently skip overdue recurring jobs. Landed from contributor PR #18925 by @rexlunae. Thanks @rexlunae.
  • +
  • Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so cron/gateway tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
  • +
  • Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
  • +
  • MS Teams/authz: keep groupPolicy: "allowlist" enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
  • +
  • Security/system.run: bind approved bun and deno run script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
  • +
  • Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
  • +
+
+]]> + + 2026.3.7 Sun, 08 Mar 2026 04:42:35 +0000 @@ -584,144 +658,5 @@ - - 2026.3.1 - Mon, 02 Mar 2026 04:40:59 +0000 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 2026030190 - 2026.3.1 - 15.0 - OpenClaw 2026.3.1 -

Changes

-
    -
  • Agents/Thinking defaults: set adaptive as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at low unless explicitly configured.
  • -
  • Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (/health, /healthz, /ready, /readyz) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
  • -
  • Android/Nodes: add camera.list, device.permissions, device.health, and notifications.actions (open/dismiss/reply) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
  • -
  • Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (idleHours, default 24h) plus optional hard maxAgeHours lifecycle controls, and add /session idle + /session max-age commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
  • -
  • Telegram/DM topics: add per-DM direct + topic config (allowlists, dmPolicy, skills, systemPrompt, requireTopic), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
  • -
  • Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
  • -
  • OpenAI/Streaming transport: make openai Responses WebSocket-first by default (transport: "auto" with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (store + context_management) on the WS path.
  • -
  • Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
  • -
  • Android/Nodes parity: add system.notify, photos.latest, contacts.search/contacts.add, calendar.events/calendar.add, and motion.activity/motion.pedometer, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
  • -
  • CLI/Config: add openclaw config file to print the active config file path resolved from OPENCLAW_CONFIG_PATH or the default location. (#26256) thanks @cyb1278588254.
  • -
  • Feishu/Docx tables + uploads: add feishu_doc actions for Docx table creation/cell writing (create_table, write_table_cells, create_table_with_values) and image/file uploads (upload_image, upload_file) with stricter create/upload error handling for missing document_id and placeholder cleanup failures. (#20304) Thanks @xuhao1.
  • -
  • Feishu/Reactions: add inbound im.message.reaction.created_v1 handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
  • -
  • Feishu/Chat tooling: add feishu_chat tool actions for chat info and member queries, with configurable enablement under channels.feishu.tools.chat. (#14674) Thanks @liuweifly.
  • -
  • Feishu/Doc permissions: support optional owner permission grant fields on feishu_doc create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
  • -
  • Web UI/i18n: add German (de) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
  • -
  • Tools/Diffs: add a new optional diffs plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
  • -
  • Memory/LanceDB: support custom OpenAI baseUrl and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
  • -
  • ACP/ACPX streaming: pin ACPX plugin support to 0.1.15, add configurable ACPX command/version probing, and streamline ACP stream delivery (final_only default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
  • -
  • Shell env markers: set OPENCLAW_SHELL across shell-like runtimes (exec, acp, acp-client, tui-local) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.
  • -
  • Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (--light-context for cron agent turns and agents.*.heartbeat.lightContext for heartbeat), keeping only HEARTBEAT.md for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
  • -
  • OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (response.create with generate:false), enable it by default for openai/*, and expose params.openaiWsWarmup for per-model enable/disable control.
  • -
  • Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (task_completion) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured internalEvents.
  • -
-

Breaking

-
    -
  • BREAKING: Node exec approval payloads now require systemRunPlan. host=node approval requests without that plan are rejected.
  • -
  • BREAKING: Node system.run execution now pins path-token commands to the canonical executable path (realpath) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example tr) must now accept canonical paths (for example /usr/bin/tr).
  • -
-

Fixes

-
    -
  • Android/Nodes reliability: reject facing=both when deviceId is set to avoid mislabeled duplicate captures, allow notification open/reply on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
  • -
  • Windows/Plugin install: avoid spawn EINVAL on Windows npm/npx invocations by resolving to node + npm CLI scripts instead of spawning .cmd directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
  • -
  • LINE/Voice transcription: classify M4A voice media as audio/mp4 (not video/mp4) by checking the MPEG-4 ftyp major brand (M4A / M4B ), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct accountId instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
  • -
  • Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
  • -
  • Android/Photos permissions: declare Android 14+ selected-photo access permission (READ_MEDIA_VISUAL_USER_SELECTED) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.
  • -
  • Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
  • -
  • Cron/Delivery: disable the agent messaging tool when delivery.mode is "none" so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
  • -
  • CLI/Cron: clarify cron list output by renaming Agent to Agent ID and adding a Model column for isolated agent-turn jobs. (#26259) Thanks @openperf.
  • -
  • Feishu/Reply media attachments: send Feishu reply mediaUrl/mediaUrls payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when mediaUrls is empty. (#28959) Thanks @icesword0760.
  • -
  • Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (SLACK_USER_TOKEN env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
  • -
  • Feishu/Outbound session routing: stop assuming bare oc_ identifiers are always group chats, honor explicit dm:/group: prefixes for oc_ chat IDs, and default ambiguous bare oc_ targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
  • -
  • Feishu/Group session routing: add configurable group session scopes (group, group_sender, group_topic, group_topic_sender) with legacy topicSessionMode=enabled compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
  • -
  • Feishu/Reply-in-thread routing: add replyInThread config (disabled|enabled) for group replies, propagate reply_in_thread across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
  • -
  • Feishu/Probe status caching: cache successful probeFeishu() bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
  • -
  • Feishu/Opus media send type: send .opus attachments with msg_type: "audio" (instead of "media") so Feishu voice messages deliver correctly while .mp4 remains msg_type: "media" and documents remain msg_type: "file". (#28269) Thanks @Glucksberg.
  • -
  • Feishu/Mobile video media type: treat inbound message_type: "media" as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
  • -
  • Feishu/Inbound sender fallback: fall back to sender_id.user_id when sender_id.open_id is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
  • -
  • Feishu/Reply context metadata: include inbound parent_id and root_id as ReplyToId/RootMessageId in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.
  • -
  • Feishu/Post embedded media: extract media tags from inbound rich-text (post) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.
  • -
  • Feishu/Local media sends: propagate mediaLocalRoots through Feishu outbound media sending into loadWebMedia so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.
  • -
  • Feishu/Group wildcard policy fallback: honor channels.feishu.groups["*"] when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.
  • -
  • Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (image stays image, non-image maps to file) to prevent reintroducing unsupported Feishu type=audio fetches. (#16311, #8746) Thanks @Yaxuan42.
  • -
  • TTS/Voice bubbles: use opus output and enable audioAsVoice routing for Feishu and WhatsApp (in addition to Telegram) so supported channels receive voice-bubble playback instead of file-style audio attachments. (#27366) Thanks @smthfoxy.
  • -
  • Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.
  • -
  • Android/Nodes notification wake flow: enable Android system.notify default allowlist, emit notifications.changed events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.
  • -
  • Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.
  • -
  • Feishu/Multi-account + reply reliability: add channels.feishu.defaultAccount outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as msg_type: "file", and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
  • -
  • Cron/Delivery: disable the agent messaging tool when delivery.mode is "none" so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
  • -
  • Feishu/Inbound rich-text parsing: preserve share_chat payload summaries when available and add explicit parsing for rich-text code/code_block/pre tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.
  • -
  • Feishu/Post markdown parsing: parse rich-text post payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755) Thanks @WilsonLiu95.
  • -
  • Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.
  • -
  • Slack/Native commands: register Slack native status as /agentstatus (Slack-reserved /status) so manifest slash command registration stays valid while text /status still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
  • -
  • Android/Camera clip: remove camera.clip HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive maxWidth values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
  • -
  • Android/Gateway canvas capability refresh: send node.canvas.capability.refresh with object params ({}) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
  • -
  • Gateway/Control UI origins: honor gateway.controlUi.allowedOrigins: ["*"] wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
  • -
  • Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
  • -
  • Agents/Sessions list transcript paths: handle missing/non-string/relative sessions.list.path values and per-agent {agentId} templates when deriving transcriptPath, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
  • -
  • Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
  • -
  • CLI/Install: add an npm-link fallback to fix CLI startup Permission denied failures (exit 127) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
  • -
  • Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
  • -
  • Plugins/NPM spec install: fix npm-spec plugin installs when npm pack output is empty by detecting newly created .tgz archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
  • -
  • Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
  • -
  • Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
  • -
  • Gateway/macOS supervised restart: actively launchctl kickstart -k during intentional supervised restarts to bypass LaunchAgent ThrottleInterval delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
  • -
  • Daemon/macOS TLS certs: default LaunchAgent service env NODE_EXTRA_CA_CERTS to /etc/ssl/cert.pem (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
  • -
  • Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
  • -
  • Feishu/Reaction notifications: add channels.feishu.reactionNotifications (off | own | all, default own) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
  • -
  • Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (429, 99991400, 99991403) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) Thanks @guoqunabc.
  • -
  • Feishu/Zalo runtime logging: replace direct console.log/error usage in Feishu typing-indicator paths and Zalo monitor paths with runtime-gated logger calls so verbosity controls are respected while preserving typing backoff behavior. (#18841) Thanks @Clawborn.
  • -
  • Feishu/Group sender allowlist fallback: add global channels.feishu.groupSenderAllowFrom sender authorization for group chats, with per-group groups..allowFrom precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.
  • -
  • Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic.
  • -
  • Feishu/Docx convert fallback chunking: recursively split oversized markdown chunks (including long no-heading sections) when document.convert hits content limits, while keeping fenced-code-aware split boundaries whenever possible. (#14402) Thanks @lml2468.
  • -
  • Feishu/API quota controls: add typingIndicator and resolveSenderNames config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
  • -
  • Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted System: context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
  • -
  • Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
  • -
  • Sessions/Internal routing: preserve established external lastTo/lastChannel routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
  • -
  • Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.
  • -
  • Auto-reply/NO_REPLY: strip NO_REPLY token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
  • -
  • Update/Global npm: fallback to --omit=optional when global npm update fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
  • -
  • Inbound metadata/Multi-account routing: include account_id in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
  • -
  • Model directives/Auth profiles: split /model profile suffixes at the first @ after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.
  • -
  • Cron/Delivery mode none: send explicit delivery: { mode: "none" } from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
  • -
  • Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
  • -
  • Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with think=off to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
  • -
  • Ollama/Embedded runner base URL precedence: prioritize configured provider baseUrl over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.
  • -
  • Agents/Failover reason classification: avoid false rate-limit classification from incidental tpm substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
  • -
  • CLI/Cron: clarify cron list output by renaming Agent to Agent ID and adding a Model column for isolated agent-turn jobs. (#26259) Thanks @openperf.
  • -
  • 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/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
  • -
  • CLI/Ollama config: allow config set for Ollama apiKey without predeclared provider config. (#29299) Thanks @vincentkoc.
  • -
  • Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
  • -
  • Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
  • -
  • Agents/Ollama: demote empty-discovery logging from warn to debug to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
  • -
  • fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
  • -
  • Docker/Image permissions: normalize /app/extensions, /app/.agent, and /app/.agents to directory mode 755 and file mode 644 during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
  • -
  • OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty baseUrl as non-direct, honor compat.supportsStore=false, and auto-inject server-side compaction context_management for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
  • -
  • Sandbox/Browser Docker: pass OPENCLAW_BROWSER_NO_SANDBOX=1 to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
  • -
  • Usage normalization: clamp negative prompt/input token values to zero (including prompt_tokens alias inputs) so /usage and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Secrets/Auth profiles: normalize inline SecretRef token/key values to canonical tokenRef/keyRef before persistence, and keep explicit keyRef precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
  • -
  • Tools/Edit workspace boundary errors: preserve the real Path escapes workspace root failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
  • -
  • Browser/Open & navigate: accept url as an alias parameter for open and navigate. (#29260) Thanks @vincentkoc.
  • -
  • Codex/Usage window: label weekly usage window as Week instead of Day. (#26267) Thanks @Sid-Qin.
  • -
  • Signal/Sync message null-handling: treat syncMessage presence (including null) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
  • -
  • Infra/fs-safe: sanitize directory-read failures so raw EISDIR text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
  • -
  • Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false cannot create directories failures in sandbox write mode. (#30610) Thanks @glitch418x.
  • -
  • Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
  • -
  • Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (198.18.0.0/15) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
  • -
  • Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.
  • -
  • Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted System: context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
  • -
  • Feishu/Multi-account + reply reliability: add channels.feishu.defaultAccount outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as msg_type: "file", and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
  • -
  • Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
  • -
-

View full changelog

-]]>
- - -
\ No newline at end of file From cc0f30f5fb64b06c38a41d7b892f4f2933f2d288 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 07:22:16 +0000 Subject: [PATCH 253/311] test: fix windows runtime and restart loop harnesses --- src/cli/gateway-cli/run-loop.test.ts | 119 ++++++++++++++++-------- src/node-host/invoke-system-run.test.ts | 13 ++- 2 files changed, 91 insertions(+), 41 deletions(-) diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index be1a6200040d0..2e8bd1c884a52 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -59,20 +59,41 @@ function removeNewSignalListeners( } } -async function withIsolatedSignals(run: () => Promise) { - const beforeSigterm = new Set( - process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>, - ); - const beforeSigint = new Set(process.listeners("SIGINT") as Array<(...args: unknown[]) => void>); - const beforeSigusr1 = new Set( - process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>, - ); +function addedSignalListener( + signal: NodeJS.Signals, + existing: Set<(...args: unknown[]) => void>, +): (() => void) | null { + const listeners = process.listeners(signal) as Array<(...args: unknown[]) => void>; + for (let i = listeners.length - 1; i >= 0; i -= 1) { + const listener = listeners[i]; + if (listener && !existing.has(listener)) { + return listener as () => void; + } + } + return null; +} + +async function withIsolatedSignals( + run: (helpers: { captureSignal: (signal: NodeJS.Signals) => () => void }) => Promise, +) { + const existingListeners = { + SIGTERM: new Set(process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>), + SIGINT: new Set(process.listeners("SIGINT") as Array<(...args: unknown[]) => void>), + SIGUSR1: new Set(process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>), + } satisfies Record void>>; + const captureSignal = (signal: NodeJS.Signals) => { + const listener = addedSignalListener(signal, existingListeners[signal]); + if (!listener) { + throw new Error(`expected new ${signal} listener`); + } + return () => listener(); + }; try { - await run(); + await run({ captureSignal }); } finally { - removeNewSignalListeners("SIGTERM", beforeSigterm); - removeNewSignalListeners("SIGINT", beforeSigint); - removeNewSignalListeners("SIGUSR1", beforeSigusr1); + removeNewSignalListeners("SIGTERM", existingListeners.SIGTERM); + removeNewSignalListeners("SIGINT", existingListeners.SIGINT); + removeNewSignalListeners("SIGUSR1", existingListeners.SIGUSR1); } } @@ -144,10 +165,11 @@ describe("runGatewayLoop", () => { it("exits 0 on SIGTERM after graceful close", async () => { vi.clearAllMocks(); - await withIsolatedSignals(async () => { + await withIsolatedSignals(async ({ captureSignal }) => { const { close, runtime, exited } = await createSignaledLoopHarness(); + const sigterm = captureSignal("SIGTERM"); - process.emit("SIGTERM"); + sigterm(); await expect(exited).resolves.toBe(0); expect(close).toHaveBeenCalledWith({ @@ -161,7 +183,7 @@ describe("runGatewayLoop", () => { it("restarts after SIGUSR1 even when drain times out, and resets lanes for the new iteration", async () => { vi.clearAllMocks(); - await withIsolatedSignals(async () => { + await withIsolatedSignals(async ({ captureSignal }) => { getActiveTaskCount.mockReturnValueOnce(2).mockReturnValueOnce(0); waitForActiveTasks.mockResolvedValueOnce({ drained: false }); @@ -171,6 +193,8 @@ describe("runGatewayLoop", () => { const closeFirst = vi.fn(async () => {}); const closeSecond = vi.fn(async () => {}); + const closeThird = vi.fn(async () => {}); + const { runtime, exited } = createRuntimeWithExitSignal(); const start = vi.fn(); let resolveFirst: (() => void) | null = null; @@ -191,24 +215,28 @@ describe("runGatewayLoop", () => { return { close: closeSecond }; }); - start.mockRejectedValueOnce(new Error("stop-loop")); + let resolveThird: (() => void) | null = null; + const startedThird = new Promise((resolve) => { + resolveThird = resolve; + }); + start.mockImplementationOnce(async () => { + resolveThird?.(); + return { close: closeThird }; + }); const { runGatewayLoop } = await import("./run-loop.js"); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - const loopPromise = runGatewayLoop({ + void runGatewayLoop({ start: start as unknown as Parameters[0]["start"], runtime: runtime as unknown as Parameters[0]["runtime"], }); await startedFirst; + const sigusr1 = captureSignal("SIGUSR1"); + const sigterm = captureSignal("SIGTERM"); expect(start).toHaveBeenCalledTimes(1); await new Promise((resolve) => setImmediate(resolve)); - process.emit("SIGUSR1"); + sigusr1(); await startedSecond; expect(start).toHaveBeenCalledTimes(2); @@ -224,9 +252,10 @@ describe("runGatewayLoop", () => { expect(markGatewaySigusr1RestartHandled).toHaveBeenCalledTimes(1); expect(resetAllLanes).toHaveBeenCalledTimes(1); - process.emit("SIGUSR1"); + sigusr1(); - await expect(loopPromise).rejects.toThrow("stop-loop"); + await startedThird; + await new Promise((resolve) => setImmediate(resolve)); expect(closeSecond).toHaveBeenCalledWith({ reason: "gateway restarting", restartExpectedMs: 1500, @@ -235,13 +264,20 @@ describe("runGatewayLoop", () => { expect(markGatewayDraining).toHaveBeenCalledTimes(2); expect(resetAllLanes).toHaveBeenCalledTimes(2); expect(acquireGatewayLock).toHaveBeenCalledTimes(3); + + sigterm(); + await expect(exited).resolves.toBe(0); + expect(closeThird).toHaveBeenCalledWith({ + reason: "gateway stopping", + restartExpectedMs: null, + }); }); }); it("releases the lock before exiting on spawned restart", async () => { vi.clearAllMocks(); - await withIsolatedSignals(async () => { + await withIsolatedSignals(async ({ captureSignal }) => { const lockRelease = vi.fn(async () => {}); acquireGatewayLock.mockResolvedValueOnce({ release: lockRelease, @@ -255,11 +291,12 @@ describe("runGatewayLoop", () => { const exitCallOrder: string[] = []; const { runtime, exited } = await createSignaledLoopHarness(exitCallOrder); + const sigusr1 = captureSignal("SIGUSR1"); lockRelease.mockImplementation(async () => { exitCallOrder.push("lockRelease"); }); - process.emit("SIGUSR1"); + sigusr1(); await exited; expect(lockRelease).toHaveBeenCalled(); @@ -271,40 +308,45 @@ describe("runGatewayLoop", () => { it("forwards lockPort to initial and restart lock acquisitions", async () => { vi.clearAllMocks(); - await withIsolatedSignals(async () => { + await withIsolatedSignals(async ({ captureSignal }) => { const closeFirst = vi.fn(async () => {}); const closeSecond = vi.fn(async () => {}); - restartGatewayProcessWithFreshPid.mockReturnValueOnce({ mode: "disabled" }); + const closeThird = vi.fn(async () => {}); + const { runtime, exited } = createRuntimeWithExitSignal(); const start = vi .fn() .mockResolvedValueOnce({ close: closeFirst }) .mockResolvedValueOnce({ close: closeSecond }) - .mockRejectedValueOnce(new Error("stop-loop")); - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + .mockResolvedValueOnce({ close: closeThird }); const { runGatewayLoop } = await import("./run-loop.js"); - const loopPromise = runGatewayLoop({ + void runGatewayLoop({ start: start as unknown as Parameters[0]["start"], runtime: runtime as unknown as Parameters[0]["runtime"], lockPort: 18789, }); - await new Promise((resolve) => setImmediate(resolve)); - process.emit("SIGUSR1"); + const sigusr1 = captureSignal("SIGUSR1"); + const sigterm = captureSignal("SIGTERM"); + + sigusr1(); await new Promise((resolve) => setImmediate(resolve)); - process.emit("SIGUSR1"); + sigusr1(); - await expect(loopPromise).rejects.toThrow("stop-loop"); + await new Promise((resolve) => setImmediate(resolve)); expect(acquireGatewayLock).toHaveBeenNthCalledWith(1, { port: 18789 }); expect(acquireGatewayLock).toHaveBeenNthCalledWith(2, { port: 18789 }); expect(acquireGatewayLock).toHaveBeenNthCalledWith(3, { port: 18789 }); + + sigterm(); + await expect(exited).resolves.toBe(0); }); }); it("exits when lock reacquire fails during in-process restart fallback", async () => { vi.clearAllMocks(); - await withIsolatedSignals(async () => { + await withIsolatedSignals(async ({ captureSignal }) => { const lockRelease = vi.fn(async () => {}); acquireGatewayLock .mockResolvedValueOnce({ @@ -317,7 +359,8 @@ describe("runGatewayLoop", () => { }); const { start, exited } = await createSignaledLoopHarness(); - process.emit("SIGUSR1"); + const sigusr1 = captureSignal("SIGUSR1"); + sigusr1(); await expect(exited).resolves.toBe(1); expect(acquireGatewayLock).toHaveBeenCalledTimes(2); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 2d78ec465009b..dfbcc6b028a53 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -229,9 +229,16 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `openclaw-${params.runtime}-path-`)); const binDir = path.join(tmp, "bin"); fs.mkdirSync(binDir, { recursive: true }); - const runtimePath = path.join(binDir, params.runtime); - fs.writeFileSync(runtimePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - fs.chmodSync(runtimePath, 0o755); + const runtimePath = + process.platform === "win32" + ? path.join(binDir, `${params.runtime}.cmd`) + : path.join(binDir, params.runtime); + const runtimeBody = + process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; + fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); + if (process.platform !== "win32") { + fs.chmodSync(runtimePath, 0o755); + } const oldPath = process.env.PATH; process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; try { From 1d3dde8d21173bda3947ad67d6b452e465bf905a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 07:27:02 +0000 Subject: [PATCH 254/311] fix(update): re-enable launchd service before updater bootstrap --- src/cli/update-cli/restart-helper.test.ts | 3 ++- src/cli/update-cli/restart-helper.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 1e15556d89e00..c8b59d69afaee 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -98,7 +98,8 @@ describe("restart-helper", () => { expect(scriptPath.endsWith(".sh")).toBe(true); expect(content).toContain("#!/bin/sh"); expect(content).toContain("launchctl kickstart -k 'gui/501/ai.openclaw.gateway'"); - // Should fall back to bootstrap when kickstart fails (service deregistered after bootout) + // Should clear disabled state and fall back to bootstrap when kickstart fails. + expect(content).toContain("launchctl enable 'gui/501/ai.openclaw.gateway'"); expect(content).toContain("launchctl bootstrap 'gui/501'"); expect(content).toContain('rm -f "$0"'); await cleanupScript(scriptPath); diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index 02ac29d03bb7f..c27f25cdc4920 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -95,8 +95,10 @@ rm -f "$0" # Wait briefly to ensure file locks are released after update. sleep 1 # Try kickstart first (works when the service is still registered). -# If it fails (e.g. after bootout), re-register via bootstrap then kickstart. +# If it fails (e.g. after bootout), clear any persisted disabled state, +# then re-register via bootstrap and kickstart. if ! launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null; then + launchctl enable 'gui/${uid}/${escaped}' 2>/dev/null launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}' 2>/dev/null launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null || true fi From d0847ee32290026bd34e4ae137d74db0b98da71f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 07:37:50 +0000 Subject: [PATCH 255/311] chore: prepare 2026.3.8 npm release --- CHANGELOG.md | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc13a8625228a..e092ef786857e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes -## 2026.3.8-beta.1 +## 2026.3.8 ### Changes @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Update/macOS launchd restart: re-enable disabled LaunchAgent services before updater bootstrap so `openclaw update` can recover from a disabled gateway service instead of leaving the restart step stuck. - macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1. - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. - Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus. diff --git a/package.json b/package.json index 236ecbb79a292..5f6d8930124cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.8-beta.1", + "version": "2026.3.8", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 3caab9260cb0a0064e6a37b2de3bedc8a547e599 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 07:42:15 +0000 Subject: [PATCH 256/311] test: narrow gateway loop signal harness --- src/cli/gateway-cli/run-loop.test.ts | 31 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 2e8bd1c884a52..9e44d67c59bb5 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -47,10 +47,10 @@ vi.mock("../../logging/subsystem.js", () => ({ createSubsystemLogger: () => gatewayLog, })); -function removeNewSignalListeners( - signal: NodeJS.Signals, - existing: Set<(...args: unknown[]) => void>, -) { +const LOOP_SIGNALS = ["SIGTERM", "SIGINT", "SIGUSR1"] as const; +type LoopSignal = (typeof LOOP_SIGNALS)[number]; + +function removeNewSignalListeners(signal: LoopSignal, existing: Set<(...args: unknown[]) => void>) { for (const listener of process.listeners(signal)) { const fn = listener as (...args: unknown[]) => void; if (!existing.has(fn)) { @@ -60,7 +60,7 @@ function removeNewSignalListeners( } function addedSignalListener( - signal: NodeJS.Signals, + signal: LoopSignal, existing: Set<(...args: unknown[]) => void>, ): (() => void) | null { const listeners = process.listeners(signal) as Array<(...args: unknown[]) => void>; @@ -74,14 +74,15 @@ function addedSignalListener( } async function withIsolatedSignals( - run: (helpers: { captureSignal: (signal: NodeJS.Signals) => () => void }) => Promise, + run: (helpers: { captureSignal: (signal: LoopSignal) => () => void }) => Promise, ) { - const existingListeners = { - SIGTERM: new Set(process.listeners("SIGTERM") as Array<(...args: unknown[]) => void>), - SIGINT: new Set(process.listeners("SIGINT") as Array<(...args: unknown[]) => void>), - SIGUSR1: new Set(process.listeners("SIGUSR1") as Array<(...args: unknown[]) => void>), - } satisfies Record void>>; - const captureSignal = (signal: NodeJS.Signals) => { + const existingListeners = Object.fromEntries( + LOOP_SIGNALS.map((signal) => [ + signal, + new Set(process.listeners(signal) as Array<(...args: unknown[]) => void>), + ]), + ) as Record void>>; + const captureSignal = (signal: LoopSignal) => { const listener = addedSignalListener(signal, existingListeners[signal]); if (!listener) { throw new Error(`expected new ${signal} listener`); @@ -91,9 +92,9 @@ async function withIsolatedSignals( try { await run({ captureSignal }); } finally { - removeNewSignalListeners("SIGTERM", existingListeners.SIGTERM); - removeNewSignalListeners("SIGINT", existingListeners.SIGINT); - removeNewSignalListeners("SIGUSR1", existingListeners.SIGUSR1); + for (const signal of LOOP_SIGNALS) { + removeNewSignalListeners(signal, existingListeners[signal]); + } } } From ce9e91fdfcc89ce16934f70c63380d3adb05cff2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 08:14:46 +0000 Subject: [PATCH 257/311] fix(launchd): harden macOS launchagent install permissions --- CHANGELOG.md | 2 ++ src/daemon/launchd.test.ts | 55 +++++++++++++++++++++++++++++++++++--- src/daemon/launchd.ts | 27 ++++++++++++++++--- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e092ef786857e..f987feeec35a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. + ## 2026.3.8 ### Changes diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3ebf2a22aed6b..99e5e1f933ef9 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -19,7 +19,9 @@ const state = vi.hoisted(() => ({ printOutput: "", bootstrapError: "", dirs: new Set(), + dirModes: new Map(), files: new Map(), + fileModes: new Map(), })); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; @@ -62,16 +64,41 @@ vi.mock("node:fs/promises", async (importOriginal) => { } throw new Error(`ENOENT: no such file or directory, access '${key}'`); }), - mkdir: vi.fn(async (p: string) => { - state.dirs.add(String(p)); + mkdir: vi.fn(async (p: string, opts?: { mode?: number }) => { + const key = String(p); + state.dirs.add(key); + state.dirModes.set(key, opts?.mode ?? 0o777); + }), + stat: vi.fn(async (p: string) => { + const key = String(p); + if (state.dirs.has(key)) { + return { mode: state.dirModes.get(key) ?? 0o777 }; + } + if (state.files.has(key)) { + return { mode: state.fileModes.get(key) ?? 0o666 }; + } + throw new Error(`ENOENT: no such file or directory, stat '${key}'`); + }), + chmod: vi.fn(async (p: string, mode: number) => { + const key = String(p); + if (state.dirs.has(key)) { + state.dirModes.set(key, mode); + return; + } + if (state.files.has(key)) { + state.fileModes.set(key, mode); + return; + } + throw new Error(`ENOENT: no such file or directory, chmod '${key}'`); }), unlink: vi.fn(async (p: string) => { state.files.delete(String(p)); }), - writeFile: vi.fn(async (p: string, data: string) => { + writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = String(p); state.files.set(key, data); state.dirs.add(String(key.split("/").slice(0, -1).join("/"))); + state.fileModes.set(key, opts?.mode ?? 0o666); }), }; return { ...wrapped, default: wrapped }; @@ -83,7 +110,9 @@ beforeEach(() => { state.printOutput = ""; state.bootstrapError = ""; state.dirs.clear(); + state.dirModes.clear(); state.files.clear(); + state.fileModes.clear(); vi.clearAllMocks(); }); @@ -255,6 +284,26 @@ describe("launchd install", () => { expect(plist).toContain(`${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}`); }); + it("tightens writable bits on launch agent dirs and plist", async () => { + const env = createDefaultLaunchdEnv(); + state.dirs.add(env.HOME!); + state.dirModes.set(env.HOME!, 0o777); + state.dirs.add("/Users/test/Library"); + state.dirModes.set("/Users/test/Library", 0o777); + + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + }); + + const plistPath = resolveLaunchAgentPlistPath(env); + expect(state.dirModes.get(env.HOME!)).toBe(0o755); + expect(state.dirModes.get("/Users/test/Library")).toBe(0o755); + expect(state.dirModes.get("/Users/test/Library/LaunchAgents")).toBe(0o755); + expect(state.fileModes.get(plistPath)).toBe(0o644); + }); + it("restarts LaunchAgent with bootout-enable-bootstrap-kickstart order", async () => { const env = createDefaultLaunchdEnv(); await restartLaunchAgent({ diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index dccea5780edd4..11e0bd50d20a6 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -25,6 +25,9 @@ import type { GatewayServiceManageArgs, } from "./service-types.js"; +const LAUNCH_AGENT_DIR_MODE = 0o755; +const LAUNCH_AGENT_PLIST_MODE = 0o644; + function resolveLaunchAgentLabel(args?: { env?: Record }): string { const envLabel = args?.env?.OPENCLAW_LAUNCHD_LABEL?.trim(); if (envLabel) { @@ -112,6 +115,20 @@ function resolveGuiDomain(): string { return `gui/${process.getuid()}`; } +async function ensureSecureDirectory(targetPath: string): Promise { + await fs.mkdir(targetPath, { recursive: true, mode: LAUNCH_AGENT_DIR_MODE }); + try { + const stat = await fs.stat(targetPath); + const mode = stat.mode & 0o777; + const tightenedMode = mode & ~0o022; + if (tightenedMode !== mode) { + await fs.chmod(targetPath, tightenedMode); + } + } catch { + // Best effort: keep install working even if chmod/stat is unavailable. + } +} + export type LaunchctlPrintInfo = { state?: string; pid?: number; @@ -382,7 +399,7 @@ export async function installLaunchAgent({ description, }: GatewayServiceInstallArgs): Promise<{ plistPath: string }> { const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); - await fs.mkdir(logDir, { recursive: true }); + await ensureSecureDirectory(logDir); const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); @@ -398,7 +415,10 @@ export async function installLaunchAgent({ } const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); - await fs.mkdir(path.dirname(plistPath), { recursive: true }); + const home = resolveHomeDir(env); + await ensureSecureDirectory(home); + await ensureSecureDirectory(path.join(home, "Library")); + await ensureSecureDirectory(path.dirname(plistPath)); const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const plist = buildLaunchAgentPlist({ @@ -410,7 +430,8 @@ export async function installLaunchAgent({ stderrPath, environment, }); - await fs.writeFile(plistPath, plist, "utf8"); + await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE }); + await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined); await execLaunchctl(["bootout", domain, plistPath]); await execLaunchctl(["unload", plistPath]); From 7217b9765833f4e9bc1c7b6c10218be8f9ad9e50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 08:33:28 +0000 Subject: [PATCH 258/311] fix(onboard): avoid persisting talk fallback on fresh setup --- ...oard-non-interactive.provider-auth.test.ts | 37 +++++++++++++++++++ src/commands/onboard-non-interactive.ts | 2 +- src/wizard/onboarding.ts | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 390d19b015494..d72de28a61daa 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -42,6 +42,11 @@ let upsertAuthProfile: typeof import("../agents/auth-profiles.js").upsertAuthPro type ProviderAuthConfigSnapshot = { auth?: { profiles?: Record }; agents?: { defaults?: { model?: { primary?: string } } }; + talk?: { + provider?: string; + apiKey?: string | { source?: string; id?: string }; + providers?: Record; + }; models?: { providers?: Record< string, @@ -357,6 +362,38 @@ describe("onboard (non-interactive): provider auth", () => { }); }); + it("does not persist talk fallback secrets when OpenAI ref onboarding starts from an empty config", async () => { + await withOnboardEnv("openclaw-onboard-openai-ref-no-talk-leak-", async (env) => { + await withEnvAsync( + { + OPENAI_API_KEY: "sk-openai-env-key", // pragma: allowlist secret + ELEVENLABS_API_KEY: "elevenlabs-env-key", // pragma: allowlist secret + }, + async () => { + const cfg = await runOnboardingAndReadConfig(env, { + authChoice: "openai-api-key", + secretInputMode: "ref", // pragma: allowlist secret + }); + + expect(cfg.agents?.defaults?.model?.primary).toBe(OPENAI_DEFAULT_MODEL); + expect(cfg.talk).toBeUndefined(); + + const store = ensureAuthProfileStore(); + const profile = store.profiles["openai:default"]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.key).toBeUndefined(); + expect(profile.keyRef).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }); + } + }, + ); + }); + }); + it.each([ { name: "anthropic", diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 4b4d1223226e6..ee2b34981803b 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -20,7 +20,7 @@ export async function runNonInteractiveOnboarding( return; } - const baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {}; + const baseConfig: OpenClawConfig = snapshot.valid ? (snapshot.exists ? snapshot.config : {}) : {}; const mode = opts.mode ?? "local"; if (mode !== "local" && mode !== "remote") { runtime.error(`Invalid --mode "${String(mode)}" (use local|remote).`); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index e2a81537eb73a..47825eeae5225 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -81,7 +81,7 @@ export async function runOnboardingWizard( await requireRiskAcknowledgement({ opts, prompter }); const snapshot = await readConfigFileSnapshot(); - let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {}; + let baseConfig: OpenClawConfig = snapshot.valid ? (snapshot.exists ? snapshot.config : {}) : {}; if (snapshot.exists && !snapshot.valid) { await prompter.note(onboardHelpers.summarizeExistingConfig(baseConfig), "Invalid config"); From f9706fde6aee29c2523b7258f3c84fba7000705c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 08:33:39 +0000 Subject: [PATCH 259/311] build: bump unreleased version to 2026.3.9 --- apps/android/app/build.gradle.kts | 4 ++-- apps/ios/ActivityWidget/Info.plist | 2 +- apps/ios/ShareExtension/Info.plist | 2 +- apps/ios/Sources/Info.plist | 2 +- apps/ios/Tests/Info.plist | 2 +- apps/ios/WatchApp/Info.plist | 2 +- apps/ios/WatchExtension/Info.plist | 2 +- apps/ios/project.yml | 14 +++++++------- apps/macos/Sources/OpenClaw/Resources/Info.plist | 2 +- docs/platforms/mac/release.md | 14 +++++++------- package.json | 2 +- src/infra/git-commit.test.ts | 2 +- src/install-sh-version.test.ts | 8 ++++---- 13 files changed, 29 insertions(+), 29 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index e300d4fb2e4fc..3b52bcf50de3a 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -63,8 +63,8 @@ android { applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 202603081 - versionName = "2026.3.8" + versionCode = 202603090 + versionName = "2026.3.9" 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/ActivityWidget/Info.plist b/apps/ios/ActivityWidget/Info.plist index e1ed12b4aa1f4..4c2d89e15667e 100644 --- a/apps/ios/ActivityWidget/Info.plist +++ b/apps/ios/ActivityWidget/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleVersion 20260308 NSExtension diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index b2e9f1eeebb42..90a7e09e0fc8b 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleVersion 20260308 NSExtension diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 99bd6f180c5de..2f1f03d24a147 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleURLTypes diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 80205f42821a9..46e3fb97eb139 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleVersion 20260308 diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index b0d365e4f7ab4..fa45d719b9c6b 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleVersion 20260308 WKCompanionAppBundleIdentifier diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 4d0bdb2ca13cc..1d898d437574a 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,7 +15,7 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleVersion 20260308 NSExtension diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 3d2bc93af8044..0664db9c6be46 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -107,7 +107,7 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" UILaunchScreen: {} UIApplicationSceneManifest: @@ -168,7 +168,7 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" NSExtension: NSExtensionPointIdentifier: com.apple.share-services @@ -205,7 +205,7 @@ targets: path: ActivityWidget/Info.plist properties: CFBundleDisplayName: OpenClaw Activity - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" NSSupportsLiveActivities: true NSExtension: @@ -231,7 +231,7 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -256,7 +256,7 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" NSExtension: NSExtensionAttributes: @@ -293,7 +293,7 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" OpenClawLogicTests: @@ -319,5 +319,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawLogicTests - CFBundleShortVersionString: "2026.3.8" + CFBundleShortVersionString: "2026.3.9" CFBundleVersion: "20260308" diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index d394013fb43bc..706fe7029c485 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.8 + 2026.3.9 CFBundleVersion 202603080 CFBundleIconFile diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 1bea6a839a070..180a52075edb2 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -39,7 +39,7 @@ Notes: # Default is auto-derived from APP_VERSION when omitted. SKIP_NOTARIZE=1 \ BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.8 \ +APP_VERSION=2026.3.9 \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh @@ -47,10 +47,10 @@ scripts/package-mac-dist.sh # `package-mac-dist.sh` already creates the zip + DMG. # If you used `package-mac-app.sh` directly instead, create them manually: # If you want notarization/stapling in this step, use the NOTARIZE command below. -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.8.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.9.zip # Optional: build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.8.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.9.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -58,13 +58,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.8.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.8 \ +APP_VERSION=2026.3.9 \ 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.3.8.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.9.dSYM.zip ``` ## Appcast entry @@ -72,7 +72,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.3.8.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.3.9.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. @@ -80,7 +80,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.3.8.zip` (and `OpenClaw-2026.3.8.dSYM.zip`) to the GitHub release for tag `v2026.3.8`. +- Upload `OpenClaw-2026.3.9.zip` (and `OpenClaw-2026.3.9.dSYM.zip`) to the GitHub release for tag `v2026.3.9`. - 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 5f6d8930124cc..bc625b74e71e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.8", + "version": "2026.3.9", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts index 26be4322ad865..d00c50fbf6f0d 100644 --- a/src/infra/git-commit.test.ts +++ b/src/infra/git-commit.test.ts @@ -198,7 +198,7 @@ describe("git commit resolution", () => { await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); await fs.writeFile( path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw", version: "2026.3.8" }), + JSON.stringify({ name: "openclaw", version: "2026.3.9" }), "utf-8", ); const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts index 4a7135925b84e..824a5366efd52 100644 --- a/src/install-sh-version.test.ts +++ b/src/install-sh-version.test.ts @@ -73,10 +73,10 @@ describe("install.sh version resolution", () => { it.runIf(process.platform !== "win32")( "extracts the semantic version from decorated CLI output", () => { - const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)"); + const fixture = withFakeCli("OpenClaw 2026.3.9 (abcdef0)"); tempRoots.push(fixture.root); - expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("2026.3.8"); + expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("2026.3.9"); }, ); @@ -93,7 +93,7 @@ describe("install.sh version resolution", () => { it.runIf(process.platform !== "win32")( "does not source version helpers from cwd when installer runs via stdin", () => { - const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)"); + const fixture = withFakeCli("OpenClaw 2026.3.9 (abcdef0)"); tempRoots.push(fixture.root); const hostileCwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-stdin-")); @@ -115,7 +115,7 @@ extract_openclaw_semver() { "utf-8", ); - expect(resolveVersionFromInstallerViaStdin(fixture.cliPath, hostileCwd)).toBe("2026.3.8"); + expect(resolveVersionFromInstallerViaStdin(fixture.cliPath, hostileCwd)).toBe("2026.3.9"); }, ); }); From 6c579d7842b537115d1354765dc945f894ebf899 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 08:37:37 +0000 Subject: [PATCH 260/311] fix: stabilize launchd paths and appcast secret scan --- .detect-secrets.cfg | 2 ++ .pre-commit-config.yaml | 2 ++ .secrets.baseline | 28 +++------------------------- src/daemon/launchd.ts | 13 +++++++------ 4 files changed, 14 insertions(+), 31 deletions(-) diff --git a/.detect-secrets.cfg b/.detect-secrets.cfg index 3ab7ebb69b5f3..34f4ff85f07f2 100644 --- a/.detect-secrets.cfg +++ b/.detect-secrets.cfg @@ -41,3 +41,5 @@ pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bash pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \}, pattern = "ap[i]Key": "xxxxx", pattern = ap[i]Key: "A[I]za\.\.\.", +# Sparkle appcast signatures are release metadata, not credentials. +pattern = sparkle:edSignature="[A-Za-z0-9+/=]+" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74dc847d48716..2f9d299a5b37f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -71,6 +71,8 @@ repos: - 'ap[i]Key: "A[I]za\.\.\.",' - --exclude-lines - '"ap[i]Key": "(resolved|normalized|legacy)-key"(,)?' + - --exclude-lines + - 'sparkle:edSignature="[A-Za-z0-9+/=]+"' # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 diff --git a/.secrets.baseline b/.secrets.baseline index 871217bc3bc3a..b1f909e6ca4e7 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -153,7 +153,8 @@ "env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},", "\"ap[i]Key\": \"xxxxx\"(,)?", "ap[i]Key: \"A[I]za\\.\\.\\.\",", - "\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?" + "\"ap[i]Key\": \"(resolved|normalized|legacy)-key\"(,)?", + "sparkle:edSignature=\"[A-Za-z0-9+/=]+\"" ] }, { @@ -180,29 +181,6 @@ "line_number": 15 } ], - "appcast.xml": [ - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "7afea670e53d801f1f881c99c40aa177e3395bfa", - "is_verified": false, - "line_number": 365 - }, - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "6e1ba26139ac4e73427e68a7eec2abf96bcf1fd4", - "is_verified": false, - "line_number": 584 - }, - { - "type": "Base64 High Entropy String", - "filename": "appcast.xml", - "hashed_secret": "c0baa9660a8d3b11874c63a535d8369f4a8fa8fa", - "is_verified": false, - "line_number": 723 - } - ], "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [ { "type": "Hex High Entropy String", @@ -13035,5 +13013,5 @@ } ] }, - "generated_at": "2026-03-09T06:30:58Z" + "generated_at": "2026-03-09T08:37:13Z" } diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 11e0bd50d20a6..492eb2e4d6eda 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -276,8 +276,8 @@ export async function uninstallLegacyLaunchAgents({ return agents; } - const home = resolveHomeDir(env); - const trashDir = path.join(home, ".Trash"); + const home = toPosixPath(resolveHomeDir(env)); + const trashDir = path.posix.join(home, ".Trash"); try { await fs.mkdir(trashDir, { recursive: true }); } catch { @@ -323,8 +323,8 @@ export async function uninstallLaunchAgent({ return; } - const home = resolveHomeDir(env); - const trashDir = path.join(home, ".Trash"); + const home = toPosixPath(resolveHomeDir(env)); + const trashDir = path.posix.join(home, ".Trash"); const dest = path.join(trashDir, `${label}.plist`); try { await fs.mkdir(trashDir, { recursive: true }); @@ -415,9 +415,10 @@ export async function installLaunchAgent({ } const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); - const home = resolveHomeDir(env); + const home = toPosixPath(resolveHomeDir(env)); + const libraryDir = path.posix.join(home, "Library"); await ensureSecureDirectory(home); - await ensureSecureDirectory(path.join(home, "Library")); + await ensureSecureDirectory(libraryDir); await ensureSecureDirectory(path.dirname(plistPath)); const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); From f6d0712f508b1f926ad6fc42f7d07b1a60e62730 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 08:39:52 +0000 Subject: [PATCH 261/311] build: sync plugin versions for 2026.3.9 --- extensions/acpx/package.json | 2 +- extensions/bluebubbles/package.json | 2 +- extensions/copilot-proxy/package.json | 2 +- extensions/diagnostics-otel/package.json | 2 +- extensions/diffs/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 +- 40 files changed, 75 insertions(+), 33 deletions(-) diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index ae560c8e9affc..27d9296a9a2e3 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/acpx", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index 80d314cee3842..3c8605ef312bf 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 9d72a35306998..e060ddd67f1a7 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "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 27dcdda7fb1ed..29c9b0ac79b89 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 5df160b125a24..b685f9851080a 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 607a0a8dd3b5e..f30f10ade519a 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 7fcd26f67fc83..fc38816e1bdab 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "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 2e5eca8365a47..2ab1c6a6ca8af 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.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 3fbfb30a4729d..2abe2abbe38ba 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 1a350de21463b..3f38e01efe11f 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 44040e8428b19..34c7de1dcfb10 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw IRC channel plugin", "type": "module", "dependencies": { diff --git a/extensions/line/package.json b/extensions/line/package.json index 227da7e8f8453..9ec37f833e7dd 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 1cdcdf7cb83f2..8a74b2ead7eb1 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index 67495efb3006c..4c137401fbb2f 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "dependencies": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 8a8bbcb87f419..a3b32a18c8520 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 11fa647936a87..c1b5859b43eb1 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 1b823e4d08bb8..d532764db8738 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Mattermost channel plugin", "type": "module", "dependencies": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index ce7521de44a8e..ca69729004781 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "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 067b32446eb2f..abd920833cab7 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "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 b588a963474fa..9443f37d524a4 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.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 4ca638b8f7c73..38d5614305c76 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index c6b4106ee056f..c4453f82f6e9e 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index bef40590adc00..96797d4b76e8d 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "dependencies": { diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 7daad7c06f956..3088efcc2bbbe 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index c6fdf0056d4b1..dbee4bc09d7f2 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "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 d323b6ca9e655..240a2bbcb4178 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "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 88fe04ac1e48a..743c8212d3172 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index cf62b6ce62758..539541bdc6d71 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index ff83998b45632..0050389881728 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index eba1967b064fb..6602b46f2c857 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 159daa5e69d3a..0cb79328d895f 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 3ef565f84a9f8..48160f427e8f2 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 04bdf10977baa..5fbf49cc971d1 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 6bd0d2aadebc6..a8a4586116cfe 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index c01c2194cb471..420f8b4156070 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index b3cf0a655c387..c87a5f26c2b26 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 647dc8c3f45a0..5ae5323034f82 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 30de1e0ea0351..6de5909736f2c 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index 15ecfd974cc8f..10c22ce402912 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.9 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.8-beta.1 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 9f352c143614f..79bf5723d48fa 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.3.8-beta.1", + "version": "2026.3.9", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { From f2f561fab1bf3808baed61ebdd55ec3bfe3c8b65 Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 9 Mar 2026 12:50:47 +0100 Subject: [PATCH 262/311] fix(ui): preserve control-ui auth across refresh (#40892) Merged via squash. Prepared head SHA: f9b2375892485e91c838215b8880d54970179153 Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/help/faq.md | 2 +- docs/web/control-ui.md | 4 +- docs/web/dashboard.md | 6 +- ui/src/ui/app-lifecycle-connect.node.test.ts | 26 ++++- ui/src/ui/app-lifecycle.ts | 2 +- ui/src/ui/app-settings.ts | 28 +++-- ui/src/ui/app.ts | 5 + ui/src/ui/navigation.browser.test.ts | 62 +++++++++- ui/src/ui/storage.node.test.ts | 117 ++++++++++++++++++- ui/src/ui/storage.ts | 77 +++++++++++- ui/src/ui/test-helpers/app-mount.ts | 2 + ui/src/ui/views/overview.ts | 6 +- 13 files changed, 312 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f987feeec35a2..4b94dca8be166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. +- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. ## 2026.3.8 diff --git a/docs/help/faq.md b/docs/help/faq.md index 0ea9c4d92d5dd..7dad0548fd4ad 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2504,7 +2504,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): -- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage. +- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence. Fix: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index bbee9443b836c..c96a91de0ba52 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -27,7 +27,7 @@ Auth is supplied during the WebSocket handshake via: - `connect.params.auth.token` - `connect.params.auth.password` - The dashboard settings panel lets you store a token; passwords are not persisted. + The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. The onboarding wizard generates a gateway token by default, so paste it here on first connect. ## Device pairing (first connection) @@ -237,7 +237,7 @@ http://localhost:5173/?gatewayUrl=wss://:18789#token= ({ +const { applySettingsFromUrlMock, connectGatewayMock, loadBootstrapMock } = vi.hoisted(() => ({ + applySettingsFromUrlMock: vi.fn(), connectGatewayMock: vi.fn(), loadBootstrapMock: vi.fn(), })); @@ -14,7 +15,7 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({ })); vi.mock("./app-settings.ts", () => ({ - applySettingsFromUrl: vi.fn(), + applySettingsFromUrl: applySettingsFromUrlMock, attachThemeListener: vi.fn(), detachThemeListener: vi.fn(), inferBasePath: vi.fn(() => "/"), @@ -65,6 +66,12 @@ function createHost() { } describe("handleConnected", () => { + beforeEach(() => { + applySettingsFromUrlMock.mockReset(); + connectGatewayMock.mockReset(); + loadBootstrapMock.mockReset(); + }); + it("waits for bootstrap load before first gateway connect", async () => { let resolveBootstrap!: () => void; loadBootstrapMock.mockReturnValueOnce( @@ -102,4 +109,17 @@ describe("handleConnected", () => { expect(connectGatewayMock).not.toHaveBeenCalled(); }); + + it("scrubs URL settings before starting the bootstrap fetch", () => { + loadBootstrapMock.mockResolvedValueOnce(undefined); + const host = createHost(); + + handleConnected(host as never); + + expect(applySettingsFromUrlMock).toHaveBeenCalledTimes(1); + expect(loadBootstrapMock).toHaveBeenCalledTimes(1); + expect(applySettingsFromUrlMock.mock.invocationCallOrder[0]).toBeLessThan( + loadBootstrapMock.mock.invocationCallOrder[0], + ); + }); }); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 815947d69720e..28fb5271ecce4 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -45,8 +45,8 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { const connectGeneration = ++host.connectGeneration; host.basePath = inferBasePath(); - const bootstrapReady = loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters[0]); + const bootstrapReady = loadControlUiBootstrapConfig(host); syncTabWithLocation(host as unknown as Parameters[0], true); syncThemeWithSettings(host as unknown as Parameters[0]); attachThemeListener(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 2c07fc0f80cea..55dd59ace0de5 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -59,6 +59,7 @@ type SettingsHost = { themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; pendingGatewayUrl?: string | null; + pendingGatewayToken?: string | null; }; export function applySettings(host: SettingsHost, next: UiSettings) { @@ -94,18 +95,26 @@ export function applySettingsFromUrl(host: SettingsHost) { const params = new URLSearchParams(url.search); const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); - const tokenRaw = params.get("token") ?? hashParams.get("token"); + const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); + const nextGatewayUrl = gatewayUrlRaw?.trim() ?? ""; + const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl); + const tokenRaw = hashParams.get("token"); const passwordRaw = params.get("password") ?? hashParams.get("password"); const sessionRaw = params.get("session") ?? hashParams.get("session"); - const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl"); let shouldCleanUrl = false; + if (params.has("token")) { + params.delete("token"); + shouldCleanUrl = true; + } + if (tokenRaw != null) { const token = tokenRaw.trim(); - if (token && token !== host.settings.token) { + if (token && gatewayUrlChanged) { + host.pendingGatewayToken = token; + } else if (token && token !== host.settings.token) { applySettings(host, { ...host.settings, token }); } - params.delete("token"); hashParams.delete("token"); shouldCleanUrl = true; } @@ -130,9 +139,14 @@ export function applySettingsFromUrl(host: SettingsHost) { } if (gatewayUrlRaw != null) { - const gatewayUrl = gatewayUrlRaw.trim(); - if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { - host.pendingGatewayUrl = gatewayUrl; + if (gatewayUrlChanged) { + host.pendingGatewayUrl = nextGatewayUrl; + if (!tokenRaw?.trim()) { + host.pendingGatewayToken = null; + } + } else { + host.pendingGatewayUrl = null; + host.pendingGatewayToken = null; } params.delete("gatewayUrl"); hashParams.delete("gatewayUrl"); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 69350b550c3aa..6467ca9e39410 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -178,6 +178,7 @@ export class OpenClawApp extends LitElement { @state() execApprovalBusy = false; @state() execApprovalError: string | null = null; @state() pendingGatewayUrl: string | null = null; + pendingGatewayToken: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; @@ -573,16 +574,20 @@ export class OpenClawApp extends LitElement { if (!nextGatewayUrl) { return; } + const nextToken = this.pendingGatewayToken?.trim() || ""; this.pendingGatewayUrl = null; + this.pendingGatewayToken = null; applySettingsInternal(this as unknown as Parameters[0], { ...this.settings, gatewayUrl: nextGatewayUrl, + token: nextToken, }); this.connect(); } handleGatewayUrlCancel() { this.pendingGatewayUrl = null; + this.pendingGatewayToken = null; } // Sidebar handlers for tool output viewing diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 8dae3fc2a1317..d9b5f3c718274 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -146,11 +146,11 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(maxScroll); }); - it("hydrates token from URL params and strips it", async () => { + it("strips query token params without importing them", async () => { const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; - expect(app.settings.token).toBe("abc123"); + expect(app.settings.token).toBe(""); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( undefined, ); @@ -167,12 +167,12 @@ describe("control UI routing", () => { expect(window.location.search).toBe(""); }); - it("hydrates token from URL params even when settings already set", async () => { + it("hydrates token from URL hash when settings already set", async () => { localStorage.setItem( "openclaw.control.settings.v1", JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }), ); - const app = mountApp("/ui/overview?token=abc123"); + const app = mountApp("/ui/overview#token=abc123"); await app.updateComplete; expect(app.settings.token).toBe("abc123"); @@ -183,7 +183,7 @@ describe("control UI routing", () => { undefined, ); expect(window.location.pathname).toBe("/ui/overview"); - expect(window.location.search).toBe(""); + expect(window.location.hash).toBe(""); }); it("hydrates token from URL hash and strips it", async () => { @@ -197,4 +197,56 @@ describe("control UI routing", () => { expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); + + it("clears the current token when the gateway URL changes", async () => { + const app = mountApp("/ui/overview#token=abc123"); + await app.updateComplete; + + const gatewayUrlInput = app.querySelector( + 'input[placeholder="ws://100.x.y.z:18789"]', + ); + expect(gatewayUrlInput).not.toBeNull(); + gatewayUrlInput!.value = "wss://other-gateway.example/openclaw"; + gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true })); + await app.updateComplete; + + expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(app.settings.token).toBe(""); + }); + + it("keeps a hash token pending until the gateway URL change is confirmed", async () => { + const app = mountApp( + "/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw#token=abc123", + ); + await app.updateComplete; + + expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw"); + expect(app.settings.token).toBe(""); + + const confirmButton = Array.from(app.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Confirm", + ); + expect(confirmButton).not.toBeUndefined(); + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await app.updateComplete; + + expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(app.settings.token).toBe("abc123"); + expect(window.location.search).toBe(""); + expect(window.location.hash).toBe(""); + }); + + it("restores the token after a same-tab refresh", async () => { + const first = mountApp("/ui/overview#token=abc123"); + await first.updateComplete; + first.remove(); + + const refreshed = mountApp("/ui/overview"); + await refreshed.updateComplete; + + expect(refreshed.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); + }); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 34563291fe36a..a6f2d3d979006 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -66,8 +66,10 @@ describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); localStorage.clear(); + sessionStorage.clear(); setControlUiBasePath(undefined); }); @@ -106,6 +108,7 @@ describe("loadSettings default gateway URL derivation", () => { host: "gateway.example:8443", pathname: "/", }); + sessionStorage.setItem("openclaw.control.token.v1", "legacy-session-token"); localStorage.setItem( "openclaw.control.settings.v1", JSON.stringify({ @@ -132,6 +135,76 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navGroupsCollapsed: {}, }); + expect(sessionStorage.length).toBe(0); + }); + + it("loads the current-tab token from sessionStorage", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "session-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "session-token", + }); + }); + + it("does not reuse a session token for a different gatewayUrl", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "gateway-a-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + gatewayUrl: "wss://other-gateway.example:8443/openclaw", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }), + ); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://other-gateway.example:8443/openclaw", + token: "", + }); }); it("does not persist gateway tokens when saving settings", async () => { @@ -141,7 +214,7 @@ describe("loadSettings default gateway URL derivation", () => { pathname: "/", }); - const { saveSettings } = await import("./storage.ts"); + const { loadSettings, saveSettings } = await import("./storage.ts"); saveSettings({ gatewayUrl: "wss://gateway.example:8443/openclaw", token: "memory-only-token", @@ -154,6 +227,10 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navGroupsCollapsed: {}, }); + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "memory-only-token", + }); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", @@ -166,5 +243,43 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navGroupsCollapsed: {}, }); + expect(sessionStorage.length).toBe(1); + }); + + it("clears the current-tab token when saving an empty token", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "stale-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + expect(loadSettings().token).toBe(""); + expect(sessionStorage.length).toBe(0); }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index b413cf38eb540..078c9bccf47f4 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,4 +1,6 @@ const KEY = "openclaw.control.settings.v1"; +const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; +const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; type PersistedUiSettings = Omit & { token?: never }; @@ -20,6 +22,72 @@ export type UiSettings = { locale?: string; }; +function getSessionStorage(): Storage | null { + if (typeof window !== "undefined" && window.sessionStorage) { + return window.sessionStorage; + } + if (typeof sessionStorage !== "undefined") { + return sessionStorage; + } + return null; +} + +function normalizeGatewayTokenScope(gatewayUrl: string): string { + const trimmed = gatewayUrl.trim(); + if (!trimmed) { + return "default"; + } + try { + const base = + typeof location !== "undefined" + ? `${location.protocol}//${location.host}${location.pathname || "/"}` + : undefined; + const parsed = base ? new URL(trimmed, base) : new URL(trimmed); + const pathname = + parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}`; + } catch { + return trimmed; + } +} + +function tokenSessionKeyForGateway(gatewayUrl: string): string { + return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; +} + +function loadSessionToken(gatewayUrl: string): string { + try { + const storage = getSessionStorage(); + if (!storage) { + return ""; + } + storage.removeItem(LEGACY_TOKEN_SESSION_KEY); + const token = storage.getItem(tokenSessionKeyForGateway(gatewayUrl)) ?? ""; + return token.trim(); + } catch { + return ""; + } +} + +function persistSessionToken(gatewayUrl: string, token: string) { + try { + const storage = getSessionStorage(); + if (!storage) { + return; + } + storage.removeItem(LEGACY_TOKEN_SESSION_KEY); + const key = tokenSessionKeyForGateway(gatewayUrl); + const normalized = token.trim(); + if (normalized) { + storage.setItem(key, normalized); + return; + } + storage.removeItem(key); + } catch { + // best-effort + } +} + export function loadSettings(): UiSettings { const defaultUrl = (() => { const proto = location.protocol === "https:" ? "wss" : "ws"; @@ -35,7 +103,7 @@ export function loadSettings(): UiSettings { const defaults: UiSettings = { gatewayUrl: defaultUrl, - token: "", + token: loadSessionToken(defaultUrl), sessionKey: "main", lastActiveSessionKey: "main", theme: "system", @@ -58,7 +126,11 @@ export function loadSettings(): UiSettings { ? parsed.gatewayUrl.trim() : defaults.gatewayUrl, // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. - token: defaults.token, + token: loadSessionToken( + typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() + ? parsed.gatewayUrl.trim() + : defaults.gatewayUrl, + ), sessionKey: typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parsed.sessionKey.trim() @@ -106,6 +178,7 @@ export function saveSettings(next: UiSettings) { } function persistSettings(next: UiSettings) { + persistSessionToken(next.gatewayUrl, next.token); const persisted: PersistedUiSettings = { gatewayUrl: next.gatewayUrl, sessionKey: next.sessionKey, diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index d6fda9475c420..e078b186203e5 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -16,12 +16,14 @@ export function registerAppMountHooks() { beforeEach(() => { window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; localStorage.clear(); + sessionStorage.clear(); document.body.innerHTML = ""; }); afterEach(() => { window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; localStorage.clear(); + sessionStorage.clear(); document.body.innerHTML = ""; }); } diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index b559fba4dabed..6ebcb884ff681 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -205,7 +205,11 @@ export function renderOverview(props: OverviewProps) { .value=${props.settings.gatewayUrl} @input=${(e: Event) => { const v = (e.target as HTMLInputElement).value; - props.onSettingsChange({ ...props.settings, gatewayUrl: v }); + props.onSettingsChange({ + ...props.settings, + gatewayUrl: v, + token: v.trim() === props.settings.gatewayUrl.trim() ? props.settings.token : "", + }); }} placeholder="ws://100.x.y.z:18789" /> From 51bae75120485d305b0bac00d59a2d80280590c2 Mon Sep 17 00:00:00 2001 From: opriz Date: Mon, 9 Mar 2026 21:28:47 +0800 Subject: [PATCH 263/311] =?UTF-8?q?fix(kimi-coding):=20fix=20kimi=20tool?= =?UTF-8?q?=20format:=20use=20native=20Anthropic=20tool=20schema=20instead?= =?UTF-8?q?=20of=20OpenAI=20=E2=80=A6=20(openclaw#40008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: opriz <51957849+opriz@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/models-config.providers.static.ts | 3 - .../pi-embedded-runner-extraparams.test.ts | 76 ++----------------- src/agents/provider-capabilities.test.ts | 10 +-- src/agents/provider-capabilities.ts | 4 +- src/config/zod-schema.core.ts | 1 + 6 files changed, 17 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b94dca8be166..29d4917ed2c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. +- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. ## 2026.3.8 diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index 638943cc4a4ce..0a766fe983e3d 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -233,9 +233,6 @@ export function buildKimiCodingProvider(): ProviderConfig { cost: KIMI_CODING_DEFAULT_COST, contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, - compat: { - requiresOpenAiAnthropicToolPayload: true, - }, }, ], }; diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 18513167a3326..c05411160752a 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -732,7 +732,7 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.thinking).toEqual({ type: "disabled" }); }); - it("normalizes kimi-coding anthropic tools to OpenAI function format", () => { + it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { @@ -746,14 +746,6 @@ describe("applyExtraParamsToAgent", () => { required: ["path"], }, }, - { - type: "function", - function: { - name: "exec", - description: "Run command", - parameters: { type: "object", properties: {} }, - }, - }, ], tool_choice: { type: "tool", name: "read" }, }; @@ -777,68 +769,16 @@ describe("applyExtraParamsToAgent", () => { expect(payloads).toHaveLength(1); expect(payloads[0]?.tools).toEqual([ { - type: "function", - function: { - name: "read", - description: "Read file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - }, - { - type: "function", - function: { - name: "exec", - description: "Run command", - parameters: { type: "object", properties: {} }, + name: "read", + description: "Read file", + input_schema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], }, }, ]); - expect(payloads[0]?.tool_choice).toEqual({ - type: "function", - function: { name: "read" }, - }); - }); - - it.each([ - { input: { type: "auto" }, expected: "auto" }, - { input: { type: "none" }, expected: "none" }, - { input: { type: "required" }, expected: "required" }, - ])("normalizes anthropic tool_choice %j for kimi-coding endpoints", ({ input, expected }) => { - const payloads: Record[] = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { - const payload: Record = { - tools: [ - { - name: "read", - description: "Read file", - input_schema: { type: "object", properties: {} }, - }, - ], - tool_choice: input, - }; - options?.onPayload?.(payload, model); - payloads.push(payload); - return {} as ReturnType; - }; - const agent = { streamFn: baseStreamFn }; - - applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low"); - - const model = { - api: "anthropic-messages", - provider: "kimi-coding", - id: "k2p5", - baseUrl: "https://api.kimi.com/coding/", - } as Model<"anthropic-messages">; - const context: Context = { messages: [] }; - void agent.streamFn?.(model, context, {}); - - expect(payloads).toHaveLength(1); - expect(payloads[0]?.tool_choice).toBe(expected); + expect(payloads[0]?.tool_choice).toEqual({ type: "tool", name: "read" }); }); it("does not rewrite anthropic tool schema for non-kimi endpoints", () => { diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 5f97ac9574659..5e162c8779409 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -31,8 +31,8 @@ describe("resolveProviderCapabilities", () => { resolveProviderCapabilities("kimi-code"), ); expect(resolveProviderCapabilities("kimi-code")).toEqual({ - anthropicToolSchemaMode: "openai-functions", - anthropicToolChoiceMode: "openai-string-modes", + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", providerFamily: "default", preserveAnthropicThinkingSignatures: false, openAiCompatTurnValidation: true, @@ -66,9 +66,9 @@ describe("resolveProviderCapabilities", () => { expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9"); }); - it("treats kimi aliases as anthropic tool payload compatibility providers", () => { - expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(true); - expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true); + it("treats kimi aliases as native anthropic tool payload providers", () => { + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(false); + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(false); expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index d12a3f0b94ef6..62007b810f8f3 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -33,9 +33,9 @@ const PROVIDER_CAPABILITIES: Record> = { "amazon-bedrock": { providerFamily: "anthropic", }, + // kimi-coding natively supports Anthropic tool framing (input_schema); + // converting to OpenAI format causes XML text fallback instead of tool_use blocks. "kimi-coding": { - anthropicToolSchemaMode: "openai-functions", - anthropicToolChoiceMode: "openai-string-modes", preserveAnthropicThinkingSignatures: false, }, mistral: { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 7ddef789282e6..23accd816371c 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -198,6 +198,7 @@ export const ModelCompatSchema = z requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(), requiresMistralToolIds: z.boolean().optional(), + requiresOpenAiAnthropicToolPayload: z.boolean().optional(), }) .strict() .optional(); From 98ea71aca584bf055aaf10dcb5e89d3bbcdb3126 Mon Sep 17 00:00:00 2001 From: Joshua Lelon Mitchell Date: Mon, 9 Mar 2026 09:30:43 -0500 Subject: [PATCH 264/311] fix(swiftformat): exclude HostEnvSecurityPolicy.generated.swift from formatters (#39969) --- .swiftformat | 2 +- .swiftlint.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.swiftformat b/.swiftformat index fd8c0e6315ce5..ab608a901784d 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index b5622880111bd..e4f925fdf20d3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -19,6 +19,8 @@ excluded: - "*.playground" # Generated (protocol-gen-swift.ts) - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift + # Generated (generate-host-env-security-policy-swift.mjs) + - apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift analyzer_rules: - unused_declaration From fbf5d56366ba1dcf01e63c18cc3a4231212b9504 Mon Sep 17 00:00:00 2001 From: Daniel Reis Date: Mon, 9 Mar 2026 16:15:35 +0100 Subject: [PATCH 265/311] test(context-engine): add bundle chunk isolation tests for registry (#40460) Merged via squash. Prepared head SHA: 44622abfbc83120912060abb1059cbca8a20be83 Co-authored-by: dsantoreis <220753637+dsantoreis@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/context-engine/context-engine.test.ts | 114 ++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d4917ed2c3d..15b57e181ff37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. +- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis. ## 2026.3.8 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 91b9ffac52453..9b40008f1a05c 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -348,3 +348,117 @@ describe("Initialization guard", () => { expect(ids).toContain("legacy"); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 7. Bundle chunk isolation (#40096) +// +// Published builds may split the context-engine registry across multiple +// output chunks. The Symbol.for() keyed global ensures that a plugin +// calling registerContextEngine() from chunk A is visible to +// resolveContextEngine() imported from chunk B. +// +// These tests exercise the invariant that failed in 2026.3.7 when +// lossless-claw registered successfully but resolution could not find it. +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Bundle chunk isolation (#40096)", () => { + it("Symbol.for key is stable across independently loaded modules", async () => { + // Simulate two distinct bundle chunks by loading the registry module + // twice with different query strings (forces separate module instances + // in Vite/esbuild but shares globalThis). + const ts = Date.now().toString(36); + const registryUrl = new URL("./registry.ts", import.meta.url).href; + + const chunkA = await import(/* @vite-ignore */ `${registryUrl}?chunk=a-${ts}`); + const chunkB = await import(/* @vite-ignore */ `${registryUrl}?chunk=b-${ts}`); + + // Chunk A registers an engine + const engineId = `cross-chunk-${ts}`; + chunkA.registerContextEngine(engineId, () => new MockContextEngine()); + + // Chunk B must see it + expect(chunkB.getContextEngineFactory(engineId)).toBeDefined(); + expect(chunkB.listContextEngineIds()).toContain(engineId); + }); + + it("resolveContextEngine from chunk B finds engine registered in chunk A", async () => { + const ts = Date.now().toString(36); + const registryUrl = new URL("./registry.ts", import.meta.url).href; + + const chunkA = await import(/* @vite-ignore */ `${registryUrl}?chunk=resolve-a-${ts}`); + const chunkB = await import(/* @vite-ignore */ `${registryUrl}?chunk=resolve-b-${ts}`); + + const engineId = `resolve-cross-${ts}`; + chunkA.registerContextEngine(engineId, () => ({ + info: { id: engineId, name: "Cross-chunk Engine", version: "0.0.1" }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }: { messages: AgentMessage[] }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + })); + + // Resolve from chunk B using a config that points to this engine + const engine = await chunkB.resolveContextEngine(configWithSlot(engineId)); + expect(engine.info.id).toBe(engineId); + }); + + it("plugin-sdk export path shares the same global registry", async () => { + // The plugin-sdk re-exports registerContextEngine. Verify the + // re-export writes to the same global symbol as the direct import. + const ts = Date.now().toString(36); + const engineId = `sdk-path-${ts}`; + + // Direct registry import + registerContextEngine(engineId, () => new MockContextEngine()); + + // Plugin-sdk import (different chunk path in the published bundle) + const sdkUrl = new URL("../plugin-sdk/index.ts", import.meta.url).href; + const sdk = await import(/* @vite-ignore */ `${sdkUrl}?sdk-${ts}`); + + // The SDK export should see the engine we just registered + const factory = getContextEngineFactory(engineId); + expect(factory).toBeDefined(); + + // And registering from the SDK path should be visible from the direct path + const sdkEngineId = `sdk-registered-${ts}`; + sdk.registerContextEngine(sdkEngineId, () => new MockContextEngine()); + expect(getContextEngineFactory(sdkEngineId)).toBeDefined(); + }); + + it("concurrent registration from multiple chunks does not lose entries", async () => { + const ts = Date.now().toString(36); + const registryUrl = new URL("./registry.ts", import.meta.url).href; + let releaseRegistrations: (() => void) | undefined; + const registrationStart = new Promise((resolve) => { + releaseRegistrations = resolve; + }); + + // Load 5 "chunks" in parallel + const chunks = await Promise.all( + Array.from( + { length: 5 }, + (_, i) => import(/* @vite-ignore */ `${registryUrl}?concurrent-${ts}-${i}`), + ), + ); + + const ids = chunks.map((_, i) => `concurrent-${ts}-${i}`); + const registrationTasks = chunks.map(async (chunk, i) => { + const id = `concurrent-${ts}-${i}`; + await registrationStart; + chunk.registerContextEngine(id, () => new MockContextEngine()); + }); + releaseRegistrations?.(); + await Promise.all(registrationTasks); + + // All 5 engines must be visible from any chunk + const allIds = chunks[0].listContextEngineIds(); + for (const id of ids) { + expect(allIds).toContain(id); + } + }); +}); From 54be30ef89f50d8811e67158bb860f020ae7a86f Mon Sep 17 00:00:00 2001 From: Charles Dusek <38732970+cgdusek@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:27:29 -0500 Subject: [PATCH 266/311] fix(agents): bound compaction retry wait and drain embedded runs on restart (#40324) Merged via squash. Prepared head SHA: cfd99562d686b21b9239d3d536c6f6aadc518138 Co-authored-by: cgdusek <38732970+cgdusek@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 19 ++- ...compaction-retry-aggregate-timeout.test.ts | 143 ++++++++++++++++++ .../run/compaction-retry-aggregate-timeout.ts | 51 +++++++ src/agents/pi-embedded-runner/runs.test.ts | 108 +++++++++++++ src/agents/pi-embedded-runner/runs.ts | 114 +++++++++++++- src/cli/gateway-cli/run-loop.test.ts | 19 ++- src/cli/gateway-cli/run-loop.ts | 35 ++++- src/infra/infra-runtime.test.ts | 4 +- src/infra/restart.ts | 3 +- 10 files changed, 478 insertions(+), 19 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts create mode 100644 src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts create mode 100644 src/agents/pi-embedded-runner/runs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b57e181ff37..7fa48053c6dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. - Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis. +- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek. ## 2026.3.8 diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b8dc464e51c6d..d7fa541c2becd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -124,6 +124,7 @@ import { installToolResultContextGuard } from "../tool-result-context-guard.js"; import { splitSdkTools } from "../tool-split.js"; import { describeUnknownError, mapThinkingLevel } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; +import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; import { selectCompactionTimeoutSnapshot, shouldFlagCompactionTimeout, @@ -1537,6 +1538,7 @@ export async function runEmbeddedAttempt( toolMetas, unsubscribe, waitForCompactionRetry, + isCompactionInFlight, getMessagingToolSentTexts, getMessagingToolSentMediaUrls, getMessagingToolSentTargets, @@ -1798,6 +1800,7 @@ export async function runEmbeddedAttempt( // Only trust snapshot if compaction wasn't running before or after capture const preCompactionSnapshot = wasCompactingBefore || wasCompactingAfter ? null : snapshot; const preCompactionSessionId = activeSession.sessionId; + const COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS = 60_000; try { // Flush buffered block replies before waiting for compaction so the @@ -1808,7 +1811,21 @@ export async function runEmbeddedAttempt( await params.onBlockReplyFlush(); } - await abortable(waitForCompactionRetry()); + const compactionRetryWait = await waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable, + aggregateTimeoutMs: COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS, + isCompactionStillInFlight: isCompactionInFlight, + }); + if (compactionRetryWait.timedOut) { + timedOutDuringCompaction = true; + if (!isProbeSession) { + log.warn( + `compaction retry aggregate timeout (${COMPACTION_RETRY_AGGREGATE_TIMEOUT_MS}ms): ` + + `proceeding with pre-compaction state runId=${params.runId} sessionId=${params.sessionId}`, + ); + } + } } catch (err) { if (isRunnerAbortError(err)) { if (!promptError) { diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts new file mode 100644 index 0000000000000..9a38127c84a1f --- /dev/null +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { waitForCompactionRetryWithAggregateTimeout } from "./compaction-retry-aggregate-timeout.js"; + +describe("waitForCompactionRetryWithAggregateTimeout", () => { + it("times out and fires callback when compaction retry never resolves", async () => { + vi.useFakeTimers(); + try { + const onTimeout = vi.fn(); + const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); + + const resultPromise = waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable: async (promise) => await promise, + aggregateTimeoutMs: 60_000, + onTimeout, + }); + + await vi.advanceTimersByTimeAsync(60_000); + const result = await resultPromise; + + expect(result.timedOut).toBe(true); + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); + + it("keeps waiting while compaction remains in flight", async () => { + vi.useFakeTimers(); + try { + const onTimeout = vi.fn(); + let compactionInFlight = true; + const waitForCompactionRetry = vi.fn( + async () => + await new Promise((resolve) => { + setTimeout(() => { + compactionInFlight = false; + resolve(); + }, 170_000); + }), + ); + + const resultPromise = waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable: async (promise) => await promise, + aggregateTimeoutMs: 60_000, + onTimeout, + isCompactionStillInFlight: () => compactionInFlight, + }); + + await vi.advanceTimersByTimeAsync(170_000); + const result = await resultPromise; + + expect(result.timedOut).toBe(false); + expect(onTimeout).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); + + it("times out after an idle timeout window", async () => { + vi.useFakeTimers(); + try { + const onTimeout = vi.fn(); + let compactionInFlight = true; + const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); + setTimeout(() => { + compactionInFlight = false; + }, 90_000); + + const resultPromise = waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable: async (promise) => await promise, + aggregateTimeoutMs: 60_000, + onTimeout, + isCompactionStillInFlight: () => compactionInFlight, + }); + + await vi.advanceTimersByTimeAsync(120_000); + const result = await resultPromise; + + expect(result.timedOut).toBe(true); + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); + + it("does not time out when compaction retry resolves", async () => { + vi.useFakeTimers(); + try { + const onTimeout = vi.fn(); + const waitForCompactionRetry = vi.fn(async () => {}); + + const result = await waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable: async (promise) => await promise, + aggregateTimeoutMs: 60_000, + onTimeout, + }); + + expect(result.timedOut).toBe(false); + expect(onTimeout).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); + + it("propagates abort errors from abortable and clears timer", async () => { + vi.useFakeTimers(); + try { + const abortError = new Error("aborted"); + abortError.name = "AbortError"; + const onTimeout = vi.fn(); + const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); + + await expect( + waitForCompactionRetryWithAggregateTimeout({ + waitForCompactionRetry, + abortable: async () => { + throw abortError; + }, + aggregateTimeoutMs: 60_000, + onTimeout, + }), + ).rejects.toThrow("aborted"); + + expect(onTimeout).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); +}); diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts new file mode 100644 index 0000000000000..464e3cfcf7fa7 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.ts @@ -0,0 +1,51 @@ +/** + * Wait for compaction retry completion with an aggregate timeout to avoid + * holding a session lane indefinitely when retry resolution is lost. + */ +export async function waitForCompactionRetryWithAggregateTimeout(params: { + waitForCompactionRetry: () => Promise; + abortable: (promise: Promise) => Promise; + aggregateTimeoutMs: number; + onTimeout?: () => void; + isCompactionStillInFlight?: () => boolean; +}): Promise<{ timedOut: boolean }> { + const timeoutMsRaw = params.aggregateTimeoutMs; + const timeoutMs = Number.isFinite(timeoutMsRaw) ? Math.max(1, Math.floor(timeoutMsRaw)) : 1; + + let timedOut = false; + const waitPromise = params.waitForCompactionRetry().then(() => "done" as const); + + while (true) { + let timer: ReturnType | undefined; + try { + const result = await params.abortable( + Promise.race([ + waitPromise, + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), timeoutMs); + }), + ]), + ); + + if (result === "done") { + break; + } + + // Keep extending the timeout window while compaction is actively running. + // We only trigger the fallback timeout once compaction appears idle. + if (params.isCompactionStillInFlight?.()) { + continue; + } + + timedOut = true; + params.onTimeout?.(); + break; + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } + } + + return { timedOut }; +} diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/pi-embedded-runner/runs.test.ts new file mode 100644 index 0000000000000..73201749317dc --- /dev/null +++ b/src/agents/pi-embedded-runner/runs.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + __testing, + abortEmbeddedPiRun, + clearActiveEmbeddedRun, + setActiveEmbeddedRun, + waitForActiveEmbeddedRuns, +} from "./runs.js"; + +describe("pi-embedded runner run registry", () => { + afterEach(() => { + __testing.resetActiveEmbeddedRuns(); + vi.restoreAllMocks(); + }); + + it("aborts only compacting runs in compacting mode", () => { + const abortCompacting = vi.fn(); + const abortNormal = vi.fn(); + + setActiveEmbeddedRun("session-compacting", { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => true, + abort: abortCompacting, + }); + + setActiveEmbeddedRun("session-normal", { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => false, + abort: abortNormal, + }); + + const aborted = abortEmbeddedPiRun(undefined, { mode: "compacting" }); + expect(aborted).toBe(true); + expect(abortCompacting).toHaveBeenCalledTimes(1); + expect(abortNormal).not.toHaveBeenCalled(); + }); + + it("aborts every active run in all mode", () => { + const abortA = vi.fn(); + const abortB = vi.fn(); + + setActiveEmbeddedRun("session-a", { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => true, + abort: abortA, + }); + + setActiveEmbeddedRun("session-b", { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => false, + abort: abortB, + }); + + const aborted = abortEmbeddedPiRun(undefined, { mode: "all" }); + expect(aborted).toBe(true); + expect(abortA).toHaveBeenCalledTimes(1); + expect(abortB).toHaveBeenCalledTimes(1); + }); + + it("waits for active runs to drain", async () => { + vi.useFakeTimers(); + try { + const handle = { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => false, + abort: vi.fn(), + }; + setActiveEmbeddedRun("session-a", handle); + setTimeout(() => { + clearActiveEmbeddedRun("session-a", handle); + }, 500); + + const waitPromise = waitForActiveEmbeddedRuns(1_000, { pollMs: 100 }); + await vi.advanceTimersByTimeAsync(500); + const result = await waitPromise; + + expect(result.drained).toBe(true); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); + + it("returns drained=false when timeout elapses", async () => { + vi.useFakeTimers(); + try { + setActiveEmbeddedRun("session-a", { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => false, + abort: vi.fn(), + }); + + const waitPromise = waitForActiveEmbeddedRuns(1_000, { pollMs: 100 }); + await vi.advanceTimersByTimeAsync(1_000); + const result = await waitPromise; + expect(result.drained).toBe(false); + } finally { + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + } + }); +}); diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 41dad4df582d8..6b62b9b59edaf 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -37,15 +37,70 @@ export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean return true; } -export function abortEmbeddedPiRun(sessionId: string): boolean { - const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); - if (!handle) { - diag.debug(`abort failed: sessionId=${sessionId} reason=no_active_run`); - return false; +/** + * Abort embedded PI runs. + * + * - With a sessionId, aborts that single run. + * - With no sessionId, supports targeted abort modes (for example, compacting runs only). + */ +export function abortEmbeddedPiRun(sessionId: string): boolean; +export function abortEmbeddedPiRun( + sessionId: undefined, + opts: { mode: "all" | "compacting" }, +): boolean; +export function abortEmbeddedPiRun( + sessionId?: string, + opts?: { mode?: "all" | "compacting" }, +): boolean { + if (typeof sessionId === "string" && sessionId.length > 0) { + const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); + if (!handle) { + diag.debug(`abort failed: sessionId=${sessionId} reason=no_active_run`); + return false; + } + diag.debug(`aborting run: sessionId=${sessionId}`); + try { + handle.abort(); + } catch (err) { + diag.warn(`abort failed: sessionId=${sessionId} err=${String(err)}`); + return false; + } + return true; } - diag.debug(`aborting run: sessionId=${sessionId}`); - handle.abort(); - return true; + + const mode = opts?.mode; + if (mode === "compacting") { + let aborted = false; + for (const [id, handle] of ACTIVE_EMBEDDED_RUNS) { + if (!handle.isCompacting()) { + continue; + } + diag.debug(`aborting compacting run: sessionId=${id}`); + try { + handle.abort(); + aborted = true; + } catch (err) { + diag.warn(`abort failed: sessionId=${id} err=${String(err)}`); + } + } + return aborted; + } + + if (mode === "all") { + let aborted = false; + for (const [id, handle] of ACTIVE_EMBEDDED_RUNS) { + diag.debug(`aborting run: sessionId=${id}`); + try { + handle.abort(); + aborted = true; + } catch (err) { + diag.warn(`abort failed: sessionId=${id} err=${String(err)}`); + } + } + return aborted; + } + + return false; } export function isEmbeddedPiRunActive(sessionId: string): boolean { @@ -68,6 +123,36 @@ export function getActiveEmbeddedRunCount(): number { return ACTIVE_EMBEDDED_RUNS.size; } +/** + * Wait for active embedded runs to drain. + * + * Used during restarts so in-flight compaction runs can release session write + * locks before the next lifecycle starts. + */ +export async function waitForActiveEmbeddedRuns( + timeoutMs = 15_000, + opts?: { pollMs?: number }, +): Promise<{ drained: boolean }> { + const pollMsRaw = opts?.pollMs ?? 250; + const pollMs = Math.max(10, Math.floor(pollMsRaw)); + const maxWaitMs = Math.max(pollMs, Math.floor(timeoutMs)); + + const startedAt = Date.now(); + while (true) { + if (ACTIVE_EMBEDDED_RUNS.size === 0) { + return { drained: true }; + } + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= maxWaitMs) { + diag.warn( + `wait for active embedded runs timed out: activeRuns=${ACTIVE_EMBEDDED_RUNS.size} timeoutMs=${maxWaitMs}`, + ); + return { drained: false }; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } +} + export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): Promise { if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId)) { return Promise.resolve(true); @@ -150,4 +235,17 @@ export function clearActiveEmbeddedRun( } } +export const __testing = { + resetActiveEmbeddedRuns() { + for (const waiters of EMBEDDED_RUN_WAITERS.values()) { + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve(true); + } + } + EMBEDDED_RUN_WAITERS.clear(); + ACTIVE_EMBEDDED_RUNS.clear(); + }, +}; + export type { EmbeddedPiQueueHandle }; diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 9e44d67c59bb5..bff37742254a4 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -15,6 +15,11 @@ const resetAllLanes = vi.fn(); const restartGatewayProcessWithFreshPid = vi.fn< () => { mode: "spawned" | "supervised" | "disabled" | "failed"; pid?: number; detail?: string } >(() => ({ mode: "disabled" })); +const abortEmbeddedPiRun = vi.fn( + (_sessionId?: string, _opts?: { mode?: "all" | "compacting" }) => false, +); +const getActiveEmbeddedRunCount = vi.fn(() => 0); +const waitForActiveEmbeddedRuns = vi.fn(async (_timeoutMs: number) => ({ drained: true })); const DRAIN_TIMEOUT_LOG = "drain timeout reached; proceeding with restart"; const gatewayLog = { info: vi.fn(), @@ -43,6 +48,13 @@ vi.mock("../../process/command-queue.js", () => ({ resetAllLanes: () => resetAllLanes(), })); +vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ + abortEmbeddedPiRun: (sessionId?: string, opts?: { mode?: "all" | "compacting" }) => + abortEmbeddedPiRun(sessionId, opts), + getActiveEmbeddedRunCount: () => getActiveEmbeddedRunCount(), + waitForActiveEmbeddedRuns: (timeoutMs: number) => waitForActiveEmbeddedRuns(timeoutMs), +})); + vi.mock("../../logging/subsystem.js", () => ({ createSubsystemLogger: () => gatewayLog, })); @@ -186,7 +198,9 @@ describe("runGatewayLoop", () => { await withIsolatedSignals(async ({ captureSignal }) => { getActiveTaskCount.mockReturnValueOnce(2).mockReturnValueOnce(0); + getActiveEmbeddedRunCount.mockReturnValueOnce(1).mockReturnValueOnce(0); waitForActiveTasks.mockResolvedValueOnce({ drained: false }); + waitForActiveEmbeddedRuns.mockResolvedValueOnce({ drained: true }); type StartServer = () => Promise<{ close: (opts: { reason: string; restartExpectedMs: number | null }) => Promise; @@ -243,7 +257,10 @@ describe("runGatewayLoop", () => { expect(start).toHaveBeenCalledTimes(2); await new Promise((resolve) => setImmediate(resolve)); - expect(waitForActiveTasks).toHaveBeenCalledWith(30_000); + expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "compacting" }); + expect(waitForActiveTasks).toHaveBeenCalledWith(90_000); + expect(waitForActiveEmbeddedRuns).toHaveBeenCalledWith(90_000); + expect(abortEmbeddedPiRun).toHaveBeenCalledWith(undefined, { mode: "all" }); expect(markGatewayDraining).toHaveBeenCalledTimes(1); expect(gatewayLog.warn).toHaveBeenCalledWith(DRAIN_TIMEOUT_LOG); expect(closeFirst).toHaveBeenCalledWith({ diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 4fbb480726445..13ef073a80d2b 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -1,3 +1,8 @@ +import { + abortEmbeddedPiRun, + getActiveEmbeddedRunCount, + waitForActiveEmbeddedRuns, +} from "../../agents/pi-embedded-runner/runs.js"; import type { startGatewayServer } from "../../gateway/server.js"; import { acquireGatewayLock } from "../../infra/gateway-lock.js"; import { restartGatewayProcessWithFreshPid } from "../../infra/process-respawn.js"; @@ -90,7 +95,7 @@ export async function runGatewayLoop(params: { exitProcess(0); }; - const DRAIN_TIMEOUT_MS = 30_000; + const DRAIN_TIMEOUT_MS = 90_000; const SHUTDOWN_TIMEOUT_MS = 5_000; const request = (action: GatewayRunSignalAction, signal: string) => { @@ -121,15 +126,33 @@ export async function runGatewayLoop(params: { // sessions get an explicit restart error instead of silent task loss. markGatewayDraining(); const activeTasks = getActiveTaskCount(); - if (activeTasks > 0) { + const activeRuns = getActiveEmbeddedRunCount(); + + // Best-effort abort for compacting runs so long compaction operations + // don't hold session write locks across restart boundaries. + if (activeRuns > 0) { + abortEmbeddedPiRun(undefined, { mode: "compacting" }); + } + + if (activeTasks > 0 || activeRuns > 0) { gatewayLog.info( - `draining ${activeTasks} active task(s) before restart (timeout ${DRAIN_TIMEOUT_MS}ms)`, + `draining ${activeTasks} active task(s) and ${activeRuns} active embedded run(s) before restart (timeout ${DRAIN_TIMEOUT_MS}ms)`, ); - const { drained } = await waitForActiveTasks(DRAIN_TIMEOUT_MS); - if (drained) { - gatewayLog.info("all active tasks drained"); + const [tasksDrain, runsDrain] = await Promise.all([ + activeTasks > 0 + ? waitForActiveTasks(DRAIN_TIMEOUT_MS) + : Promise.resolve({ drained: true }), + activeRuns > 0 + ? waitForActiveEmbeddedRuns(DRAIN_TIMEOUT_MS) + : Promise.resolve({ drained: true }), + ]); + if (tasksDrain.drained && runsDrain.drained) { + gatewayLog.info("all active work drained"); } else { gatewayLog.warn("drain timeout reached; proceeding with restart"); + // Final best-effort abort to avoid carrying active runs into the + // next lifecycle when drain time budget is exhausted. + abortEmbeddedPiRun(undefined, { mode: "all" }); } } } diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 6a406e8113b39..e7656de974f19 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -244,8 +244,8 @@ describe("infra runtime", () => { await vi.advanceTimersByTimeAsync(0); expect(emitSpy).not.toHaveBeenCalledWith("SIGUSR1"); - // Advance past the 30s max deferral wait - await vi.advanceTimersByTimeAsync(30_000); + // Advance past the 90s max deferral wait + await vi.advanceTimersByTimeAsync(90_000); expect(emitSpy).toHaveBeenCalledWith("SIGUSR1"); } finally { process.removeListener("SIGUSR1", handler); diff --git a/src/infra/restart.ts b/src/infra/restart.ts index ddb4352e5cac0..3e0379f25f2dd 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -19,7 +19,8 @@ export type RestartAttempt = { const SPAWN_TIMEOUT_MS = 2000; const SIGUSR1_AUTH_GRACE_MS = 5000; const DEFAULT_DEFERRAL_POLL_MS = 500; -const DEFAULT_DEFERRAL_MAX_WAIT_MS = 30_000; +// Cover slow in-flight embedded compaction work before forcing restart. +const DEFAULT_DEFERRAL_MAX_WAIT_MS = 90_000; const RESTART_COOLDOWN_MS = 30_000; const restartLog = createSubsystemLogger("restart"); From 425bd89b48dd4c01966b1633943717b2d9896a7f Mon Sep 17 00:00:00 2001 From: xaeon2026 Date: Mon, 9 Mar 2026 12:08:11 -0400 Subject: [PATCH 267/311] Allow ACP sessions.patch lineage fields on ACP session keys (#40995) Merged via squash. Prepared head SHA: c1191edc08618dec1826c57b75556c4e35ccccaf Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/gateway/sessions-patch.test.ts | 23 +++++++++++++++++++++++ src/gateway/sessions-patch.ts | 13 +++++++++---- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa48053c6dbf..25398c660f42f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. - Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis. - Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek. +- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026. ## 2026.3.8 diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 78d8a71aecb83..2249c7f5c771e 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -252,6 +252,29 @@ describe("gateway sessions patch", () => { expect(entry.spawnDepth).toBe(2); }); + test("sets spawnedBy for ACP sessions", async () => { + const entry = expectPatchOk( + await runPatch({ + storeKey: "agent:main:acp:child", + patch: { + key: "agent:main:acp:child", + spawnedBy: "agent:main:main", + }, + }), + ); + expect(entry.spawnedBy).toBe("agent:main:main"); + }); + + test("sets spawnDepth for ACP sessions", async () => { + const entry = expectPatchOk( + await runPatch({ + storeKey: "agent:main:acp:child", + patch: { key: "agent:main:acp:child", spawnDepth: 2 }, + }), + ); + expect(entry.spawnDepth).toBe(2); + }); + test("rejects spawnDepth on non-subagent sessions", async () => { const result = await runPatch({ patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 }, diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index d55cf2cf1a417..b4e5ce6e06eb5 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -19,6 +19,7 @@ import { import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { + isAcpSessionKey, isSubagentSessionKey, normalizeAgentId, parseAgentSessionKey, @@ -62,6 +63,10 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } +function supportsSpawnLineage(storeKey: string): boolean { + return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey); +} + export async function applySessionsPatchToStore(params: { cfg: OpenClawConfig; store: Record; @@ -97,8 +102,8 @@ export async function applySessionsPatchToStore(params: { if (!trimmed) { return invalid("invalid spawnedBy: empty"); } - if (!isSubagentSessionKey(storeKey)) { - return invalid("spawnedBy is only supported for subagent:* sessions"); + if (!supportsSpawnLineage(storeKey)) { + return invalid("spawnedBy is only supported for subagent:* or acp:* sessions"); } if (existing?.spawnedBy && existing.spawnedBy !== trimmed) { return invalid("spawnedBy cannot be changed once set"); @@ -114,8 +119,8 @@ export async function applySessionsPatchToStore(params: { return invalid("spawnDepth cannot be cleared once set"); } } else if (raw !== undefined) { - if (!isSubagentSessionKey(storeKey)) { - return invalid("spawnDepth is only supported for subagent:* sessions"); + if (!supportsSpawnLineage(storeKey)) { + return invalid("spawnDepth is only supported for subagent:* or acp:* sessions"); } const numeric = Number(raw); if (!Number.isInteger(numeric) || numeric < 0) { From 258b7902a43570330a5d0c434becea41f31dcc6a Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 9 Mar 2026 17:13:16 +0100 Subject: [PATCH 268/311] Update CONTRIBUTING.md --- CONTRIBUTING.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30b2ca0f0ea36..223ca9ee75497 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,10 +57,20 @@ Welcome to the lobster tank! 🦞 - GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant) - **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT - - Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) + - GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) + - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - - Github [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) + - GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) +- **Radek Sienkiewicz** - Control UI + WebChat correctness + - GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark) + +- **Muhammed Mukhthar** - Compaction, Tlon/Urbit subsystem + - GitHub [@mukhtharcm](https://github.com/mukhtharcm) · X: [@mukhtharcm](https://x.com/mukhtharcm) + +- **Altay** - Agents, CLI, error handling + - GitHub [@altaywtf](https://github.com/altaywtf) · X: [@altaywtf](https://x.com/altaywtf) + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! From 2cce45962f9c11f5cc399d8e5555f4de4dc61141 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Mon, 9 Mar 2026 17:23:56 +0100 Subject: [PATCH 269/311] Add Robin Waslander to maintainers --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 223ca9ee75497..a31072dec2066 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,9 @@ Welcome to the lobster tank! 🦞 - **Altay** - Agents, CLI, error handling - GitHub [@altaywtf](https://github.com/altaywtf) · X: [@altaywtf](https://x.com/altaywtf) + +- **Robin Waslander** - Security, PR triage, bug fixes + - GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander) ## How to Contribute From 4815dc0603df5b83da5fada5d1944ee750ac4bad Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 9 Mar 2026 17:27:29 +0100 Subject: [PATCH 270/311] Update CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a31072dec2066..3d02d1f2059c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Welcome to the lobster tank! 🦞 - **Radek Sienkiewicz** - Control UI + WebChat correctness - GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark) -- **Muhammed Mukhthar** - Compaction, Tlon/Urbit subsystem +- **Muhammed Mukhthar** - Mattermost, CLI - GitHub [@mukhtharcm](https://github.com/mukhtharcm) · X: [@mukhtharcm](https://x.com/mukhtharcm) - **Altay** - Agents, CLI, error handling From eab39c721b48045c96a80fe4d80955cd9ed3fb0e Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Mon, 9 Mar 2026 09:37:33 -0700 Subject: [PATCH 271/311] fix(acp): map error states to end_turn instead of unconditional refusal (#41187) * fix(acp): map error states to end_turn instead of unconditional refusal * fix: map ACP error stop reason to end_turn (#41187) (thanks @pejmanjohn) --------- Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com> Co-authored-by: Onur --- CHANGELOG.md | 1 + src/acp/translator.stop-reason.test.ts | 111 +++++++++++++++++++++++++ src/acp/translator.ts | 6 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/acp/translator.stop-reason.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25398c660f42f..375d906bd34c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis. - Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek. - ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026. +- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn. ## 2026.3.8 diff --git a/src/acp/translator.stop-reason.test.ts b/src/acp/translator.stop-reason.test.ts new file mode 100644 index 0000000000000..6e4a2f135af55 --- /dev/null +++ b/src/acp/translator.stop-reason.test.ts @@ -0,0 +1,111 @@ +import type { PromptRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; +import { createInMemorySessionStore } from "./session.js"; +import { AcpGatewayAgent } from "./translator.js"; +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; + +type PendingPromptHarness = { + agent: AcpGatewayAgent; + promptPromise: ReturnType; + runId: string; +}; + +async function createPendingPromptHarness(): Promise { + const sessionId = "session-1"; + const sessionKey = "agent:main:main"; + + let runId: string | undefined; + const request = vi.fn(async (method: string, params?: Record) => { + if (method === "chat.send") { + runId = params?.idempotencyKey as string | undefined; + return new Promise(() => {}); + } + return {}; + }) as GatewayClient["request"]; + + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId, + sessionKey, + cwd: "/tmp", + }); + + const agent = new AcpGatewayAgent( + createAcpConnection(), + createAcpGateway(request as unknown as GatewayClient["request"]), + { sessionStore }, + ); + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "hello" }], + _meta: {}, + } as unknown as PromptRequest); + + await vi.waitFor(() => { + expect(runId).toBeDefined(); + }); + + return { + agent, + promptPromise, + runId: runId!, + }; +} + +function createChatEvent(payload: Record): EventFrame { + return { + type: "event", + event: "chat", + payload, + } as EventFrame; +} + +describe("acp translator stop reason mapping", () => { + it("error state resolves as end_turn, not refusal", async () => { + const { agent, promptPromise, runId } = await createPendingPromptHarness(); + + await agent.handleGatewayEvent( + createChatEvent({ + runId, + sessionKey: "agent:main:main", + seq: 1, + state: "error", + errorMessage: "gateway timeout", + }), + ); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + }); + + it("error state with no errorMessage resolves as end_turn", async () => { + const { agent, promptPromise, runId } = await createPendingPromptHarness(); + + await agent.handleGatewayEvent( + createChatEvent({ + runId, + sessionKey: "agent:main:main", + seq: 1, + state: "error", + }), + ); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + }); + + it("aborted state resolves as cancelled", async () => { + const { agent, promptPromise, runId } = await createPendingPromptHarness(); + + await agent.handleGatewayEvent( + createChatEvent({ + runId, + sessionKey: "agent:main:main", + seq: 1, + state: "aborted", + }), + ); + + await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" }); + }); +}); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 0dbf3099c3dc1..7cf556f3e7573 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -473,7 +473,11 @@ export class AcpGatewayAgent implements Agent { return; } if (state === "error") { - this.finishPrompt(pending.sessionId, pending, "refusal"); + // ACP has no explicit "server_error" stop reason. Use "end_turn" so clients + // do not treat transient backend errors (timeouts, rate-limits) as deliberate + // refusals. TODO: when ChatEventSchema gains a structured errorKind field + // (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here. + this.finishPrompt(pending.sessionId, pending, "end_turn"); } } From 14bbcad1695de811e37faea9ed445a6a5684265f Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Mon, 9 Mar 2026 09:50:38 -0700 Subject: [PATCH 272/311] fix(acp): propagate setSessionMode gateway errors to client (#41185) * fix(acp): propagate setSessionMode gateway errors to client * fix: add changelog entry for ACP setSessionMode propagation (#41185) (thanks @pejmanjohn) --------- Co-authored-by: Pejman Pour-Moezzi <481729+pejmanjohn@users.noreply.github.com> Co-authored-by: Onur --- CHANGELOG.md | 1 + src/acp/translator.set-session-mode.test.ts | 61 +++++++++++++++++++++ src/acp/translator.ts | 1 + 3 files changed, 63 insertions(+) create mode 100644 src/acp/translator.set-session-mode.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 375d906bd34c8..1e51ea3a0a1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek. - ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026. - ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn. +- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. ## 2026.3.8 diff --git a/src/acp/translator.set-session-mode.test.ts b/src/acp/translator.set-session-mode.test.ts new file mode 100644 index 0000000000000..53e8db0e5e527 --- /dev/null +++ b/src/acp/translator.set-session-mode.test.ts @@ -0,0 +1,61 @@ +import type { SetSessionModeRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { GatewayClient } from "../gateway/client.js"; +import { createInMemorySessionStore } from "./session.js"; +import { AcpGatewayAgent } from "./translator.js"; +import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; + +function createSetSessionModeRequest(modeId: string): SetSessionModeRequest { + return { + sessionId: "session-1", + modeId, + } as unknown as SetSessionModeRequest; +} + +function createAgentWithSession(request: GatewayClient["request"]) { + const sessionStore = createInMemorySessionStore(); + sessionStore.createSession({ + sessionId: "session-1", + sessionKey: "agent:main:main", + cwd: "/tmp", + }); + return new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { + sessionStore, + }); +} + +describe("acp setSessionMode", () => { + it("setSessionMode propagates gateway error", async () => { + const request = vi.fn(async () => { + throw new Error("gateway rejected mode change"); + }) as GatewayClient["request"]; + const agent = createAgentWithSession(request); + + await expect(agent.setSessionMode(createSetSessionModeRequest("high"))).rejects.toThrow( + "gateway rejected mode change", + ); + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "agent:main:main", + thinkingLevel: "high", + }); + }); + + it("setSessionMode succeeds when gateway accepts", async () => { + const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; + const agent = createAgentWithSession(request); + + await expect(agent.setSessionMode(createSetSessionModeRequest("low"))).resolves.toEqual({}); + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "agent:main:main", + thinkingLevel: "low", + }); + }); + + it("setSessionMode returns early for empty modeId", async () => { + const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; + const agent = createAgentWithSession(request); + + await expect(agent.setSessionMode(createSetSessionModeRequest(""))).resolves.toEqual({}); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 7cf556f3e7573..d399228afa666 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -256,6 +256,7 @@ export class AcpGatewayAgent implements Agent { this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); + throw err; } return {}; } From 12702e11a50abac5e96956ee8743064494e240d1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 11:20:33 -0700 Subject: [PATCH 273/311] plugins: harden global hook runner state (#40184) --- src/plugins/hook-runner-global.test.ts | 49 ++++++++++++++++++++++++++ src/plugins/hook-runner-global.ts | 34 +++++++++++++----- 2 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 src/plugins/hook-runner-global.test.ts diff --git a/src/plugins/hook-runner-global.test.ts b/src/plugins/hook-runner-global.test.ts new file mode 100644 index 0000000000000..8089feff43083 --- /dev/null +++ b/src/plugins/hook-runner-global.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMockPluginRegistry } from "./hooks.test-helpers.js"; + +async function importHookRunnerGlobalModule() { + return import("./hook-runner-global.js"); +} + +afterEach(async () => { + const mod = await importHookRunnerGlobalModule(); + mod.resetGlobalHookRunner(); + vi.resetModules(); +}); + +describe("hook-runner-global", () => { + it("preserves the initialized runner across module reloads", async () => { + const modA = await importHookRunnerGlobalModule(); + const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]); + + modA.initializeGlobalHookRunner(registry); + expect(modA.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + + vi.resetModules(); + + const modB = await importHookRunnerGlobalModule(); + expect(modB.getGlobalHookRunner()).not.toBeNull(); + expect(modB.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true); + expect(modB.getGlobalPluginRegistry()).toBe(registry); + }); + + it("clears the shared state across module reloads", async () => { + const modA = await importHookRunnerGlobalModule(); + const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]); + + modA.initializeGlobalHookRunner(registry); + + vi.resetModules(); + + const modB = await importHookRunnerGlobalModule(); + modB.resetGlobalHookRunner(); + expect(modB.getGlobalHookRunner()).toBeNull(); + expect(modB.getGlobalPluginRegistry()).toBeNull(); + + vi.resetModules(); + + const modC = await importHookRunnerGlobalModule(); + expect(modC.getGlobalHookRunner()).toBeNull(); + expect(modC.getGlobalPluginRegistry()).toBeNull(); + }); +}); diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index 609721fcb4d7f..b2613f3467fa3 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -12,16 +12,31 @@ import type { PluginHookGatewayContext, PluginHookGatewayStopEvent } from "./typ const log = createSubsystemLogger("plugins"); -let globalHookRunner: HookRunner | null = null; -let globalRegistry: PluginRegistry | null = null; +type HookRunnerGlobalState = { + hookRunner: HookRunner | null; + registry: PluginRegistry | null; +}; + +const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state"); + +function getHookRunnerGlobalState(): HookRunnerGlobalState { + const globalStore = globalThis as typeof globalThis & { + [hookRunnerGlobalStateKey]?: HookRunnerGlobalState; + }; + return (globalStore[hookRunnerGlobalStateKey] ??= { + hookRunner: null, + registry: null, + }); +} /** * Initialize the global hook runner with a plugin registry. * Called once when plugins are loaded during gateway startup. */ export function initializeGlobalHookRunner(registry: PluginRegistry): void { - globalRegistry = registry; - globalHookRunner = createHookRunner(registry, { + const state = getHookRunnerGlobalState(); + state.registry = registry; + state.hookRunner = createHookRunner(registry, { logger: { debug: (msg) => log.debug(msg), warn: (msg) => log.warn(msg), @@ -41,7 +56,7 @@ export function initializeGlobalHookRunner(registry: PluginRegistry): void { * Returns null if plugins haven't been loaded yet. */ export function getGlobalHookRunner(): HookRunner | null { - return globalHookRunner; + return getHookRunnerGlobalState().hookRunner; } /** @@ -49,14 +64,14 @@ export function getGlobalHookRunner(): HookRunner | null { * Returns null if plugins haven't been loaded yet. */ export function getGlobalPluginRegistry(): PluginRegistry | null { - return globalRegistry; + return getHookRunnerGlobalState().registry; } /** * Check if any hooks are registered for a given hook name. */ export function hasGlobalHooks(hookName: Parameters[0]): boolean { - return globalHookRunner?.hasHooks(hookName) ?? false; + return getHookRunnerGlobalState().hookRunner?.hasHooks(hookName) ?? false; } export async function runGlobalGatewayStopSafely(params: { @@ -83,6 +98,7 @@ export async function runGlobalGatewayStopSafely(params: { * Reset the global hook runner (for testing). */ export function resetGlobalHookRunner(): void { - globalHookRunner = null; - globalRegistry = null; + const state = getHookRunnerGlobalState(); + state.hookRunner = null; + state.registry = null; } From 7b88249c9e03b9a7eeaa45630c1867ca78f0b885 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 11:21:19 -0700 Subject: [PATCH 274/311] fix(telegram): bridge direct delivery to internal message:sent hooks (#40185) * telegram: bridge direct delivery message hooks * telegram: align sent hooks with command session --- src/telegram/bot-message-dispatch.ts | 3 + src/telegram/bot-native-commands.ts | 28 +++++-- src/telegram/bot/delivery.replies.ts | 120 ++++++++++++++++++++------- src/telegram/bot/delivery.test.ts | 90 ++++++++++++++++++++ 4 files changed, 203 insertions(+), 38 deletions(-) diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 63e7b6e8e8f06..d4c2f7107b6a7 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -433,6 +433,9 @@ export const dispatchTelegramMessage = async ({ const deliveryBaseOptions = { chatId: String(chatId), accountId: route.accountId, + sessionKeyForInternalHooks: ctxPayload.SessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, token: opts.token, runtime, bot, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index cb29f258f1058..17958daa289ae 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -516,6 +516,9 @@ export const registerTelegramNativeCommands = ({ const buildCommandDeliveryBaseOptions = (params: { chatId: string | number; accountId: string; + sessionKeyForInternalHooks?: string; + mirrorIsGroup?: boolean; + mirrorGroupId?: string; mediaLocalRoots?: readonly string[]; threadSpec: ReturnType; tableMode: ReturnType; @@ -523,6 +526,9 @@ export const registerTelegramNativeCommands = ({ }) => ({ chatId: String(params.chatId), accountId: params.accountId, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + mirrorIsGroup: params.mirrorIsGroup, + mirrorGroupId: params.mirrorGroupId, token: opts.token, runtime, bot, @@ -589,14 +595,6 @@ export const registerTelegramNativeCommands = ({ return; } const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; - const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ - chatId, - accountId: route.accountId, - mediaLocalRoots, - threadSpec, - tableMode, - chunkMode, - }); const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; const commandDefinition = findCommandByNativeName(command.name, "telegram"); @@ -671,6 +669,17 @@ export const registerTelegramNativeCommands = ({ userId: String(senderId || chatId), targetSessionKey: sessionKey, }); + const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ + chatId, + accountId: route.accountId, + sessionKeyForInternalHooks: commandSessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, + mediaLocalRoots, + threadSpec, + tableMode, + chunkMode, + }); const conversationLabel = isGroup ? msg.chat.title ? `${msg.chat.title} id:${chatId}` @@ -827,6 +836,9 @@ export const registerTelegramNativeCommands = ({ const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ chatId, accountId: route.accountId, + sessionKeyForInternalHooks: route.sessionKey, + mirrorIsGroup: isGroup, + mirrorGroupId: isGroup ? String(chatId) : undefined, mediaLocalRoots, threadSpec, tableMode, diff --git a/src/telegram/bot/delivery.replies.ts b/src/telegram/bot/delivery.replies.ts index e4ec4e8627931..5f5edd3b83733 100644 --- a/src/telegram/bot/delivery.replies.ts +++ b/src/telegram/bot/delivery.replies.ts @@ -4,6 +4,14 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; import type { MarkdownTableMode } from "../../config/types.base.js"; import { danger, logVerbose } from "../../globals.js"; +import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; +import { + buildCanonicalSentMessageHookContext, + toInternalMessageSentContext, + toPluginMessageContext, + toPluginMessageSentEvent, +} from "../../hooks/message-hook-mappers.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildOutboundMediaLoadOptions } from "../../media/load-options.js"; import { isGifMedia, kindFromMime } from "../../media/mime.js"; @@ -493,10 +501,68 @@ async function maybePinFirstDeliveredMessage(params: { } } +function emitMessageSentHooks(params: { + hookRunner: ReturnType; + enabled: boolean; + sessionKeyForInternalHooks?: string; + chatId: string; + accountId?: string; + content: string; + success: boolean; + error?: string; + messageId?: number; + isGroup?: boolean; + groupId?: string; +}): void { + if (!params.enabled && !params.sessionKeyForInternalHooks) { + return; + } + const canonical = buildCanonicalSentMessageHookContext({ + to: params.chatId, + content: params.content, + success: params.success, + error: params.error, + channelId: "telegram", + accountId: params.accountId, + conversationId: params.chatId, + messageId: typeof params.messageId === "number" ? String(params.messageId) : undefined, + isGroup: params.isGroup, + groupId: params.groupId, + }); + if (params.enabled) { + fireAndForgetHook( + Promise.resolve( + params.hookRunner!.runMessageSent( + toPluginMessageSentEvent(canonical), + toPluginMessageContext(canonical), + ), + ), + "telegram: message_sent plugin hook failed", + ); + } + if (!params.sessionKeyForInternalHooks) { + return; + } + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent( + "message", + "sent", + params.sessionKeyForInternalHooks, + toInternalMessageSentContext(canonical), + ), + ), + "telegram: message:sent internal hook failed", + ); +} + export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; accountId?: string; + sessionKeyForInternalHooks?: string; + mirrorIsGroup?: boolean; + mirrorGroupId?: string; token: string; runtime: RuntimeEnv; bot: Bot; @@ -622,37 +688,31 @@ export async function deliverReplies(params: { firstDeliveredMessageId, }); - if (hasMessageSentHooks) { - const deliveredThisReply = progress.deliveredCount > deliveredCountBeforeReply; - void hookRunner?.runMessageSent( - { - to: params.chatId, - content: contentForSentHook, - success: deliveredThisReply, - }, - { - channelId: "telegram", - accountId: params.accountId, - conversationId: params.chatId, - }, - ); - } + emitMessageSentHooks({ + hookRunner, + enabled: hasMessageSentHooks, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + chatId: params.chatId, + accountId: params.accountId, + content: contentForSentHook, + success: progress.deliveredCount > deliveredCountBeforeReply, + messageId: firstDeliveredMessageId, + isGroup: params.mirrorIsGroup, + groupId: params.mirrorGroupId, + }); } catch (error) { - if (hasMessageSentHooks) { - void hookRunner?.runMessageSent( - { - to: params.chatId, - content: contentForSentHook, - success: false, - error: error instanceof Error ? error.message : String(error), - }, - { - channelId: "telegram", - accountId: params.accountId, - conversationId: params.chatId, - }, - ); - } + emitMessageSentHooks({ + hookRunner, + enabled: hasMessageSentHooks, + sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + chatId: params.chatId, + accountId: params.accountId, + content: contentForSentHook, + success: false, + error: error instanceof Error ? error.message : String(error), + isGroup: params.mirrorIsGroup, + groupId: params.mirrorGroupId, + }); throw error; } } diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index cda30ea4e31d3..c21e55ccf6ced 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); +const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {})); const messageHookRunner = vi.hoisted(() => ({ hasHooks: vi.fn<(name: string) => boolean>(() => false), runMessageSending: vi.fn(), @@ -31,6 +32,16 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => messageHookRunner, })); +vi.mock("../../hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../../hooks/internal-hooks.js", + ); + return { + ...actual, + triggerInternalHook, + }; +}); + vi.mock("grammy", () => ({ InputFile: class { constructor( @@ -108,6 +119,7 @@ function createVoiceFailureHarness(params: { describe("deliverReplies", () => { beforeEach(() => { loadWebMedia.mockClear(); + triggerInternalHook.mockReset(); messageHookRunner.hasHooks.mockReset(); messageHookRunner.hasHooks.mockReturnValue(false); messageHookRunner.runMessageSending.mockReset(); @@ -199,6 +211,84 @@ describe("deliverReplies", () => { ); }); + it("emits internal message:sent when session hook context is available", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:123", + mirrorIsGroup: true, + mirrorGroupId: "123", + replies: [{ text: "hello" }], + runtime, + bot, + }); + + expect(triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "sent", + sessionKey: "agent:test:telegram:123", + context: expect.objectContaining({ + to: "123", + content: "hello", + success: true, + channelId: "telegram", + conversationId: "123", + messageId: "9", + isGroup: true, + groupId: "123", + }), + }), + ); + }); + + it("does not emit internal message:sent without a session key", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + }); + + expect(triggerInternalHook).not.toHaveBeenCalled(); + }); + + it("emits internal message:sent with success=false on delivery failure", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockRejectedValue(new Error("network error")); + const bot = createBot({ sendMessage }); + + await expect( + deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:123", + replies: [{ text: "hello" }], + runtime, + bot, + }), + ).rejects.toThrow("network error"); + + expect(triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + type: "message", + action: "sent", + sessionKey: "agent:test:telegram:123", + context: expect.objectContaining({ + to: "123", + content: "hello", + success: false, + error: "network error", + channelId: "telegram", + conversationId: "123", + }), + }), + ); + }); + it("passes media metadata to message_sending hooks", async () => { messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending"); From d4e59a3666d810f9574392c70abb942e0c3b0dd8 Mon Sep 17 00:00:00 2001 From: Mariano Date: Mon, 9 Mar 2026 20:12:37 +0100 Subject: [PATCH 275/311] Cron: enforce cron-owned delivery contract (#40998) Merged via squash. Prepared head SHA: 5877389e33d5b3a518925b5793a6f6294cb3fb3d Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 2 + CONTRIBUTING.md | 3 +- docs/automation/cron-jobs.md | 1 + docs/cli/cron.md | 6 + docs/cli/doctor.md | 1 + docs/gateway/doctor.md | 20 + src/cli/cron-cli/register.ts | 2 +- src/commands/doctor-cron.test.ts | 158 ++++++ src/commands/doctor-cron.ts | 186 +++++++ src/commands/doctor.ts | 6 + src/cron/delivery.failure-notify.test.ts | 143 +++++ src/cron/delivery.test.ts | 40 ++ .../isolated-agent.delivery.test-helpers.ts | 2 + ...p-recipient-besteffortdeliver-true.test.ts | 2 + .../delivery-dispatch.double-announce.test.ts | 43 +- .../delivery-dispatch.named-agent.test.ts | 9 + src/cron/isolated-agent/delivery-dispatch.ts | 18 +- .../run.message-tool-policy.test.ts | 20 +- src/cron/isolated-agent/run.ts | 24 +- src/cron/service.delivery-plan.test.ts | 8 +- ...ce.heartbeat-ok-summary-suppressed.test.ts | 9 +- ...runs-one-shot-main-job-disables-it.test.ts | 12 +- src/cron/service/store.ts | 435 +--------------- src/cron/service/timer.ts | 42 -- src/cron/store-migration.test.ts | 78 +++ src/cron/store-migration.ts | 491 ++++++++++++++++++ src/gateway/server.cron.test.ts | 28 +- src/gateway/server.hooks.test.ts | 4 + src/gateway/server/hooks.ts | 1 + 29 files changed, 1277 insertions(+), 517 deletions(-) create mode 100644 src/commands/doctor-cron.test.ts create mode 100644 src/commands/doctor-cron.ts create mode 100644 src/cron/delivery.failure-notify.test.ts create mode 100644 src/cron/store-migration.test.ts create mode 100644 src/cron/store-migration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e51ea3a0a1f3..4be8bad0eaa7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai ### Breaking +- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky. + ### Fixes - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d02d1f2059c9..1127d7dc79168 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,6 @@ Welcome to the lobster tank! 🦞 - **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT - GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik) - - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) @@ -73,7 +72,7 @@ Welcome to the lobster tank! 🦞 - **Robin Waslander** - Security, PR triage, bug fixes - GitHub: [@hydro13](https://github.com/hydro13) · X: [@Robin_waslander](https://x.com/Robin_waslander) - + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 47bae78b86fb7..a0b5e50547671 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -29,6 +29,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. - Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. - Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. +- For upgrades, `openclaw doctor --fix` can normalize legacy cron store fields before the scheduler touches them. ## Quick start (actionable) diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 28e61e20c993b..6ee2585974934 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -30,6 +30,12 @@ Note: retention/pruning is controlled in config: - `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions. - `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/.jsonl`. +Upgrade note: if you have older cron jobs from before the current delivery/store format, run +`openclaw doctor --fix`. Doctor now normalizes legacy cron fields (`jobId`, `schedule.cron`, +top-level delivery fields, payload `provider` delivery aliases) and migrates simple +`notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is +configured. + ## Common edits Update delivery settings without changing the message: diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index d53d86452f3b4..90e5fa7d7a2ed 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -28,6 +28,7 @@ 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 also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - 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`). diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 2550406f4ffda..b46b90520d108 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -65,6 +65,7 @@ cat ~/.openclaw/openclaw.json - Config normalization for legacy values. - OpenCode Zen provider override warnings (`models.providers.opencode`). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). +- Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, simple `notify: true` webhook fallback jobs). - State integrity and permissions checks (sessions, transcripts, state dir). - Config file permission checks (chmod 600) when running locally. - Model auth health: checks OAuth expiry, can refresh expiring tokens, and reports auth-profile cooldown/disabled states. @@ -158,6 +159,25 @@ the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `openclaw doctor`. +### 3b) Legacy cron store migrations + +Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default, +or `cron.store` when overridden) for old job shapes that the scheduler still +accepts for compatibility. + +Current cron cleanups include: + +- `jobId` → `id` +- `schedule.cron` → `schedule.expr` +- top-level payload fields (`message`, `model`, `thinking`, ...) → `payload` +- top-level delivery fields (`deliver`, `channel`, `to`, `provider`, ...) → `delivery` +- payload `provider` delivery aliases → explicit `delivery.channel` +- simple legacy `notify: true` webhook fallback jobs → explicit `delivery.mode="webhook"` with `delivery.to=cron.webhook` + +Doctor only auto-migrates `notify: true` jobs when it can do so without +changing behavior. If a job combines legacy notify fallback with an existing +non-webhook delivery mode, doctor warns and leaves that job for manual review. + ### 4) State integrity checks (session persistence, routing, and safety) The state directory is the operational brainstem. If it vanishes, you lose diff --git a/src/cli/cron-cli/register.ts b/src/cli/cron-cli/register.ts index a796583fa21ff..35f80dbda0675 100644 --- a/src/cli/cron-cli/register.ts +++ b/src/cli/cron-cli/register.ts @@ -16,7 +16,7 @@ export function registerCronCli(program: Command) { .addHelpText( "after", () => - `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/cron", "docs.openclaw.ai/cli/cron")}\n`, + `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/cron", "docs.openclaw.ai/cli/cron")}\n${theme.muted("Upgrade tip:")} run \`openclaw doctor --fix\` to normalize legacy cron job storage.\n`, ); registerCronStatusCommand(cron); diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts new file mode 100644 index 0000000000000..8c9faf0e24d5d --- /dev/null +++ b/src/commands/doctor-cron.test.ts @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import * as noteModule from "../terminal/note.js"; +import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; + +let tempRoot: string | null = null; + +async function makeTempStorePath() { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-cron-")); + return path.join(tempRoot, "cron", "jobs.json"); +} + +afterEach(async () => { + vi.restoreAllMocks(); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } +}); + +function makePrompter(confirmResult = true) { + return { + confirm: vi.fn().mockResolvedValue(confirmResult), + }; +} + +describe("maybeRepairLegacyCronStore", () => { + it("repairs legacy cron store fields and migrates notify fallback to webhook delivery", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + jobId: "legacy-job", + name: "Legacy job", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "cron", cron: "0 7 * * *", tz: "UTC" }, + payload: { + kind: "systemEvent", + text: "Morning brief", + }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const cfg: OpenClawConfig = { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }; + + await maybeRepairLegacyCronStore({ + cfg, + options: {}, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + const [job] = persisted.jobs; + expect(job?.jobId).toBeUndefined(); + expect(job?.id).toBe("legacy-job"); + expect(job?.notify).toBeUndefined(); + expect(job?.schedule).toMatchObject({ + kind: "cron", + expr: "0 7 * * *", + tz: "UTC", + }); + expect(job?.delivery).toMatchObject({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + expect(job?.payload).toMatchObject({ + kind: "systemEvent", + text: "Morning brief", + }); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("Legacy cron job storage detected"), + "Cron", + ); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining("Cron store normalized"), + "Doctor changes", + ); + }); + + it("warns instead of replacing announce delivery for notify fallback jobs", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + id: "notify-and-announce", + name: "Notify and announce", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Status" }, + delivery: { mode: "announce", channel: "telegram", to: "123" }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + options: { nonInteractive: true }, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.notify).toBe(true); + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining('uses legacy notify fallback alongside delivery mode "announce"'), + "Doctor warnings", + ); + }); +}); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts new file mode 100644 index 0000000000000..3dc6275e80026 --- /dev/null +++ b/src/commands/doctor-cron.ts @@ -0,0 +1,186 @@ +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { normalizeStoredCronJobs } from "../cron/store-migration.js"; +import { resolveCronStorePath, loadCronStore, saveCronStore } from "../cron/store.js"; +import type { CronJob } from "../cron/types.js"; +import { note } from "../terminal/note.js"; +import { shortenHomePath } from "../utils.js"; +import type { DoctorPrompter, DoctorOptions } from "./doctor-prompter.js"; + +type CronDoctorOutcome = { + changed: boolean; + warnings: string[]; +}; + +function pluralize(count: number, noun: string) { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function formatLegacyIssuePreview(issues: Partial>): string[] { + const lines: string[] = []; + if (issues.jobId) { + lines.push(`- ${pluralize(issues.jobId, "job")} still uses legacy \`jobId\``); + } + if (issues.legacyScheduleString) { + lines.push( + `- ${pluralize(issues.legacyScheduleString, "job")} stores schedule as a bare string`, + ); + } + if (issues.legacyScheduleCron) { + lines.push(`- ${pluralize(issues.legacyScheduleCron, "job")} still uses \`schedule.cron\``); + } + if (issues.legacyPayloadKind) { + lines.push(`- ${pluralize(issues.legacyPayloadKind, "job")} needs payload kind normalization`); + } + if (issues.legacyPayloadProvider) { + lines.push( + `- ${pluralize(issues.legacyPayloadProvider, "job")} still uses payload \`provider\` as a delivery alias`, + ); + } + if (issues.legacyTopLevelPayloadFields) { + lines.push( + `- ${pluralize(issues.legacyTopLevelPayloadFields, "job")} still uses top-level payload fields`, + ); + } + if (issues.legacyTopLevelDeliveryFields) { + lines.push( + `- ${pluralize(issues.legacyTopLevelDeliveryFields, "job")} still uses top-level delivery fields`, + ); + } + if (issues.legacyDeliveryMode) { + lines.push( + `- ${pluralize(issues.legacyDeliveryMode, "job")} still uses delivery mode \`deliver\``, + ); + } + return lines; +} + +function trimString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function migrateLegacyNotifyFallback(params: { + jobs: Array>; + legacyWebhook?: string; +}): CronDoctorOutcome { + let changed = false; + const warnings: string[] = []; + + for (const raw of params.jobs) { + if (!("notify" in raw)) { + continue; + } + + const jobName = trimString(raw.name) ?? trimString(raw.id) ?? ""; + const notify = raw.notify === true; + if (!notify) { + delete raw.notify; + changed = true; + continue; + } + + const delivery = + raw.delivery && typeof raw.delivery === "object" && !Array.isArray(raw.delivery) + ? (raw.delivery as Record) + : null; + const mode = trimString(delivery?.mode)?.toLowerCase(); + const to = trimString(delivery?.to); + + if (mode === "webhook" && to) { + delete raw.notify; + changed = true; + continue; + } + + if ((mode === undefined || mode === "none" || mode === "webhook") && params.legacyWebhook) { + raw.delivery = { + ...delivery, + mode: "webhook", + to: to ?? params.legacyWebhook, + }; + delete raw.notify; + changed = true; + continue; + } + + if (!params.legacyWebhook) { + warnings.push( + `Cron job "${jobName}" still uses legacy notify fallback, but cron.webhook is unset so doctor cannot migrate it automatically.`, + ); + continue; + } + + warnings.push( + `Cron job "${jobName}" uses legacy notify fallback alongside delivery mode "${mode}". Migrate it manually so webhook delivery does not replace existing announce behavior.`, + ); + } + + return { changed, warnings }; +} + +export async function maybeRepairLegacyCronStore(params: { + cfg: OpenClawConfig; + options: DoctorOptions; + prompter: Pick; +}) { + const storePath = resolveCronStorePath(params.cfg.cron?.store); + const store = await loadCronStore(storePath); + const rawJobs = (store.jobs ?? []) as unknown as Array>; + if (rawJobs.length === 0) { + return; + } + + const normalized = normalizeStoredCronJobs(rawJobs); + const legacyWebhook = trimString(params.cfg.cron?.webhook); + const notifyCount = rawJobs.filter((job) => job.notify === true).length; + const previewLines = formatLegacyIssuePreview(normalized.issues); + if (notifyCount > 0) { + previewLines.push( + `- ${pluralize(notifyCount, "job")} still uses legacy \`notify: true\` webhook fallback`, + ); + } + if (previewLines.length === 0) { + return; + } + + note( + [ + `Legacy cron job storage detected at ${shortenHomePath(storePath)}.`, + ...previewLines, + `Repair with ${formatCliCommand("openclaw doctor --fix")} to normalize the store before the next scheduler run.`, + ].join("\n"), + "Cron", + ); + + const shouldRepair = + params.options.nonInteractive === true + ? true + : await params.prompter.confirm({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); + if (!shouldRepair) { + return; + } + + const notifyMigration = migrateLegacyNotifyFallback({ + jobs: rawJobs, + legacyWebhook, + }); + const changed = normalized.mutated || notifyMigration.changed; + if (!changed && notifyMigration.warnings.length === 0) { + return; + } + + if (changed) { + await saveCronStore(storePath, { + version: 1, + jobs: rawJobs as unknown as CronJob[], + }); + note(`Cron store normalized at ${shortenHomePath(storePath)}.`, "Doctor changes"); + } + + if (notifyMigration.warnings.length > 0) { + note(notifyMigration.warnings.join("\n"), "Doctor warnings"); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 2688774b8bb7c..bdde2781ff904 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -31,6 +31,7 @@ import { import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js"; import { doctorShellCompletion } from "./doctor-completion.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; +import { maybeRepairLegacyCronStore } from "./doctor-cron.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; import { checkGatewayHealth, probeGatewayMemoryStatus } from "./doctor-gateway-health.js"; import { @@ -220,6 +221,11 @@ export async function doctorCommand( await noteStateIntegrity(cfg, prompter, configResult.path ?? CONFIG_PATH); await noteSessionLockHealth({ shouldRepair: prompter.shouldRepair }); + await maybeRepairLegacyCronStore({ + cfg, + options, + prompter, + }); cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); noteSandboxScopeWarnings(cfg); diff --git a/src/cron/delivery.failure-notify.test.ts b/src/cron/delivery.failure-notify.test.ts new file mode 100644 index 0000000000000..98cb437c96189 --- /dev/null +++ b/src/cron/delivery.failure-notify.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveDeliveryTarget: vi.fn(), + deliverOutboundPayloads: vi.fn(), + resolveAgentOutboundIdentity: vi.fn().mockReturnValue({ kind: "identity" }), + buildOutboundSessionContext: vi.fn().mockReturnValue({ kind: "session" }), + createOutboundSendDeps: vi.fn().mockReturnValue({ kind: "deps" }), + warn: vi.fn(), +})); + +vi.mock("./isolated-agent/delivery-target.js", () => ({ + resolveDeliveryTarget: mocks.resolveDeliveryTarget, +})); + +vi.mock("../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, +})); + +vi.mock("../infra/outbound/identity.js", () => ({ + resolveAgentOutboundIdentity: mocks.resolveAgentOutboundIdentity, +})); + +vi.mock("../infra/outbound/session-context.js", () => ({ + buildOutboundSessionContext: mocks.buildOutboundSessionContext, +})); + +vi.mock("../cli/outbound-send-deps.js", () => ({ + createOutboundSendDeps: mocks.createOutboundSendDeps, +})); + +vi.mock("../logging.js", () => ({ + getChildLogger: vi.fn(() => ({ + warn: mocks.warn, + })), +})); + +const { sendFailureNotificationAnnounce } = await import("./delivery.js"); + +describe("sendFailureNotificationAnnounce", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveDeliveryTarget.mockResolvedValue({ + ok: true, + channel: "telegram", + to: "123", + accountId: "bot-a", + threadId: 42, + mode: "explicit", + }); + mocks.deliverOutboundPayloads.mockResolvedValue([{ ok: true }]); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("delivers failure alerts to the resolved explicit target with strict send settings", async () => { + const deps = {} as never; + const cfg = {} as never; + + await sendFailureNotificationAnnounce( + deps, + cfg, + "main", + "job-1", + { channel: "telegram", to: "123", accountId: "bot-a" }, + "Cron failed", + ); + + expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith(cfg, "main", { + channel: "telegram", + to: "123", + accountId: "bot-a", + }); + expect(mocks.buildOutboundSessionContext).toHaveBeenCalledWith({ + cfg, + agentId: "main", + sessionKey: "cron:job-1:failure", + }); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + channel: "telegram", + to: "123", + accountId: "bot-a", + threadId: 42, + payloads: [{ text: "Cron failed" }], + session: { kind: "session" }, + identity: { kind: "identity" }, + bestEffort: false, + deps: { kind: "deps" }, + abortSignal: expect.any(AbortSignal), + }), + ); + }); + + it("does not send when target resolution fails", async () => { + mocks.resolveDeliveryTarget.mockResolvedValue({ + ok: false, + error: new Error("target missing"), + }); + + await sendFailureNotificationAnnounce( + {} as never, + {} as never, + "main", + "job-1", + { channel: "telegram", to: "123" }, + "Cron failed", + ); + + expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(mocks.warn).toHaveBeenCalledWith( + { error: "target missing" }, + "cron: failed to resolve failure destination target", + ); + }); + + it("swallows outbound delivery errors after logging", async () => { + mocks.deliverOutboundPayloads.mockRejectedValue(new Error("send failed")); + + await expect( + sendFailureNotificationAnnounce( + {} as never, + {} as never, + "main", + "job-1", + { channel: "telegram", to: "123" }, + "Cron failed", + ), + ).resolves.toBeUndefined(); + + expect(mocks.warn).toHaveBeenCalledWith( + expect.objectContaining({ + err: "send failed", + channel: "telegram", + to: "123", + }), + "cron: failure destination announce failed", + ); + }); +}); diff --git a/src/cron/delivery.test.ts b/src/cron/delivery.test.ts index 81ab672af57e1..43eaa21511474 100644 --- a/src/cron/delivery.test.ts +++ b/src/cron/delivery.test.ts @@ -148,6 +148,46 @@ describe("resolveFailureDestination", () => { expect(plan).toBeNull(); }); + it("returns null when webhook failure destination matches the primary webhook target", () => { + const plan = resolveFailureDestination( + makeJob({ + sessionTarget: "main", + payload: { kind: "systemEvent", text: "tick" }, + delivery: { + mode: "webhook", + to: "https://example.invalid/cron", + failureDestination: { + mode: "webhook", + to: "https://example.invalid/cron", + }, + }, + }), + undefined, + ); + expect(plan).toBeNull(); + }); + + it("does not reuse inherited announce recipient when switching failure destination to webhook", () => { + const plan = resolveFailureDestination( + makeJob({ + delivery: { + mode: "announce", + channel: "telegram", + to: "111", + failureDestination: { + mode: "webhook", + }, + }, + }), + { + channel: "signal", + to: "group-abc", + mode: "announce", + }, + ); + expect(plan).toBeNull(); + }); + it("allows job-level failure destination fields to clear inherited global values", () => { const plan = resolveFailureDestination( makeJob({ diff --git a/src/cron/isolated-agent.delivery.test-helpers.ts b/src/cron/isolated-agent.delivery.test-helpers.ts index fe6dad727f426..de4caee3a3cb3 100644 --- a/src/cron/isolated-agent.delivery.test-helpers.ts +++ b/src/cron/isolated-agent.delivery.test-helpers.ts @@ -54,6 +54,7 @@ export async function runTelegramAnnounceTurn(params: { to?: string; bestEffort?: boolean; }; + deliveryContract?: "cron-owned" | "shared"; }): Promise>> { return runCronIsolatedAgentTurn({ cfg: makeCfg(params.home, params.storePath, { @@ -67,5 +68,6 @@ export async function runTelegramAnnounceTurn(params: { message: "do it", sessionKey: "cron:job-1", lane: "cron", + deliveryContract: params.deliveryContract, }); } diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 6b2ab85739a20..52a3c1328f917 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -23,6 +23,7 @@ async function runExplicitTelegramAnnounceTurn(params: { home: string; storePath: string; deps: CliDeps; + deliveryContract?: "cron-owned" | "shared"; }): Promise>> { return runTelegramAnnounceTurn({ ...params, @@ -301,6 +302,7 @@ describe("runCronIsolatedAgentTurn", () => { home, storePath, deps, + deliveryContract: "shared", }); expectDeliveredOk(res); diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index f9a7d90a276df..9da88bbb4a3d2 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -10,7 +10,7 @@ * returning so the timer correctly skips the system-event fallback. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- Module mocks (must be hoisted before imports) --- @@ -105,7 +105,6 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested resolvedDelivery, deliveryRequested: overrides.deliveryRequested ?? true, skipHeartbeatDelivery: false, - skipMessagingToolDelivery: false, deliveryBestEffort: false, deliveryPayloadHasStructuredContent: false, deliveryPayloads: overrides.synthesizedText ? [{ text: overrides.synthesizedText }] : [], @@ -134,6 +133,10 @@ describe("dispatchCronDelivery — double-announce guard", () => { vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("early return (active subagent) sets deliveryAttempted=true so timer skips enqueueSystemEvent", async () => { // countActiveDescendantRuns returns >0 → enters wait block; still >0 after wait → early return vi.mocked(countActiveDescendantRuns).mockReturnValue(2); @@ -255,6 +258,42 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); }); + it("retries transient direct announce failures before succeeding", async () => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads) + .mockRejectedValueOnce(new Error("ECONNRESET while sending")) + .mockResolvedValueOnce([{ ok: true } as never]); + + const params = makeBaseParams({ synthesizedText: "Retry me once." }); + const state = await dispatchCronDelivery(params); + + expect(state.result).toBeUndefined(); + expect(state.deliveryAttempted).toBe(true); + expect(state.delivered).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(2); + }); + + it("does not retry permanent direct announce failures", async () => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockRejectedValue(new Error("chat not found")); + + const params = makeBaseParams({ synthesizedText: "This should fail once." }); + const state = await dispatchCronDelivery(params); + + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(state.result).toEqual( + expect.objectContaining({ + status: "error", + error: "Error: chat not found", + deliveryAttempted: true, + }), + ); + }); + it("no delivery requested means deliveryAttempted stays false and no delivery is sent", async () => { const params = makeBaseParams({ synthesizedText: "Task done.", diff --git a/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts b/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts index 6de820392410c..c5d7ec9b41c79 100644 --- a/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts @@ -96,4 +96,13 @@ describe("resolveCronDeliveryBestEffort", () => { } as never; expect(resolveCronDeliveryBestEffort(job)).toBe(true); }); + + it("lets explicit delivery.bestEffort=false override legacy payload bestEffortDeliver=true", async () => { + const { resolveCronDeliveryBestEffort } = await import("./delivery-dispatch.js"); + const job = { + delivery: { bestEffort: false }, + payload: { kind: "agentTurn", bestEffortDeliver: true }, + } as never; + expect(resolveCronDeliveryBestEffort(job)).toBe(false); + }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index a3a98b245d07b..fa9a295a77780 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -83,7 +83,7 @@ type DispatchCronDeliveryParams = { resolvedDelivery: DeliveryTargetResolution; deliveryRequested: boolean; skipHeartbeatDelivery: boolean; - skipMessagingToolDelivery: boolean; + skipMessagingToolDelivery?: boolean; deliveryBestEffort: boolean; deliveryPayloadHasStructuredContent: boolean; deliveryPayloads: ReplyPayload[]; @@ -192,15 +192,17 @@ async function retryTransientDirectCronDelivery(params: { export async function dispatchCronDelivery( params: DispatchCronDeliveryParams, ): Promise { + const skipMessagingToolDelivery = params.skipMessagingToolDelivery === true; let summary = params.summary; let outputText = params.outputText; let synthesizedText = params.synthesizedText; let deliveryPayloads = params.deliveryPayloads; - // `true` means we confirmed at least one outbound send reached the target. - // Keep this strict so timer fallback can safely decide whether to wake main. - let delivered = params.skipMessagingToolDelivery; - let deliveryAttempted = params.skipMessagingToolDelivery; + // Shared callers can treat a matching message-tool send as the completed + // delivery path. Cron-owned callers keep this false so direct cron delivery + // remains the only source of delivered state. + let delivered = skipMessagingToolDelivery; + let deliveryAttempted = skipMessagingToolDelivery; const failDeliveryTarget = (error: string) => params.withRunSession({ status: "error", @@ -404,11 +406,7 @@ export async function dispatchCronDelivery( } }; - if ( - params.deliveryRequested && - !params.skipHeartbeatDelivery && - !params.skipMessagingToolDelivery - ) { + if (params.deliveryRequested && !params.skipHeartbeatDelivery && !skipMessagingToolDelivery) { if (!params.resolvedDelivery.ok) { if (!params.deliveryBestEffort) { return { diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 360f0794616dd..2d576900b9da7 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -55,7 +55,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { restoreFastTestEnv(previousFastTestEnv); }); - it('keeps the message tool enabled when delivery.mode is "none"', async () => { + it('disables the message tool when delivery.mode is "none"', async () => { mockFallbackPassthrough(); resolveCronDeliveryPlanMock.mockReturnValue({ requested: false, @@ -65,7 +65,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { await runCronIsolatedAgentTurn(makeParams()); expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(false); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); }); it("disables the message tool when cron delivery is active", async () => { @@ -82,4 +82,20 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true); }); + + it("keeps the message tool enabled for shared callers when delivery is not requested", async () => { + mockFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: false, + mode: "none", + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + deliveryContract: "shared", + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(false); + }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 813b99c0553bd..5b665b6bf8fed 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -78,11 +78,10 @@ export type RunCronAgentTurnResult = { /** Last non-empty agent text output (not truncated). */ outputText?: string; /** - * `true` when the isolated run already delivered its output to the target - * channel (via outbound payloads, the subagent announce flow, or a matching - * messaging-tool send). Callers should skip posting a summary to the main - * session to avoid duplicate - * messages. See: https://github.com/openclaw/openclaw/issues/15692 + * `true` when the isolated runner already handled the run's user-visible + * delivery outcome. Cron-owned callers use this for cron delivery or + * explicit suppression; shared callers may also use it for a matching + * message-tool send that already reached the target. */ delivered?: boolean; /** @@ -144,16 +143,22 @@ function buildCronAgentDefaultsConfig(params: { type ResolvedCronDeliveryTarget = Awaited>; +type IsolatedDeliveryContract = "cron-owned" | "shared"; + function resolveCronToolPolicy(params: { deliveryRequested: boolean; resolvedDelivery: ResolvedCronDeliveryTarget; + deliveryContract: IsolatedDeliveryContract; }) { return { // Only enforce an explicit message target when the cron delivery target // was successfully resolved. When resolution fails the agent should not // be blocked by a target it cannot satisfy (#27898). requireExplicitMessageTarget: params.deliveryRequested && params.resolvedDelivery.ok, - disableMessageTool: params.deliveryRequested, + // Cron-owned runs always route user-facing delivery through the runner + // itself. Shared callers keep the previous behavior so non-cron paths do + // not silently lose the message tool when no explicit delivery is active. + disableMessageTool: params.deliveryContract === "cron-owned" ? true : params.deliveryRequested, }; } @@ -161,6 +166,7 @@ async function resolveCronDeliveryContext(params: { cfg: OpenClawConfig; job: CronJob; agentId: string; + deliveryContract: IsolatedDeliveryContract; }) { const deliveryPlan = resolveCronDeliveryPlan(params.job); const resolvedDelivery = await resolveDeliveryTarget(params.cfg, params.agentId, { @@ -176,6 +182,7 @@ async function resolveCronDeliveryContext(params: { toolPolicy: resolveCronToolPolicy({ deliveryRequested: deliveryPlan.requested, resolvedDelivery, + deliveryContract: params.deliveryContract, }), }; } @@ -200,6 +207,7 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: string; agentId?: string; lane?: string; + deliveryContract?: IsolatedDeliveryContract; }): Promise { const abortSignal = params.abortSignal ?? params.signal; const isAborted = () => abortSignal?.aborted === true; @@ -210,6 +218,7 @@ export async function runCronIsolatedAgentTurn(params: { : "cron: job execution timed out"; }; const isFastTestEnv = process.env.OPENCLAW_TEST_FAST === "1"; + const deliveryContract = params.deliveryContract ?? "cron-owned"; const defaultAgentId = resolveDefaultAgentId(params.cfg); const requestedAgentId = typeof params.agentId === "string" && params.agentId.trim() @@ -425,6 +434,7 @@ export async function runCronIsolatedAgentTurn(params: { cfg: cfgWithAgentDefaults, job: params.job, agentId, + deliveryContract, }); const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now); @@ -807,6 +817,7 @@ export async function runCronIsolatedAgentTurn(params: { const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars); const skipMessagingToolDelivery = + deliveryContract === "shared" && deliveryRequested && finalRunResult.didSendViaMessagingTool === true && (finalRunResult.messagingToolSentTargets ?? []).some((target) => @@ -816,7 +827,6 @@ export async function runCronIsolatedAgentTurn(params: { accountId: resolvedDelivery.accountId, }), ); - const deliveryResult = await dispatchCronDelivery({ cfg: params.cfg, cfgWithAgentDefaults, diff --git a/src/cron/service.delivery-plan.test.ts b/src/cron/service.delivery-plan.test.ts index 46c240e6c0f59..5168d8bebc9c0 100644 --- a/src/cron/service.delivery-plan.test.ts +++ b/src/cron/service.delivery-plan.test.ts @@ -86,7 +86,7 @@ describe("CronService delivery plan consistency", () => { }); }); - it("treats delivery object without mode as announce", async () => { + it("treats delivery object without mode as announce without reviving legacy relay fallback", async () => { await withCronService({}, async ({ cron, enqueueSystemEvent }) => { const job = await addIsolatedAgentTurnJob(cron, { name: "partial-delivery", @@ -96,10 +96,8 @@ describe("CronService delivery plan consistency", () => { const result = await cron.run(job.id, "force"); expect(result).toEqual({ ok: true, ran: true }); - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "Cron: done", - expect.objectContaining({ agentId: undefined }), - ); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(cron.getJob(job.id)?.state.lastDeliveryStatus).toBe("unknown"); }); }); diff --git a/src/cron/service.heartbeat-ok-summary-suppressed.test.ts b/src/cron/service.heartbeat-ok-summary-suppressed.test.ts index 3ae9fc7c758d4..d2a620e1439ca 100644 --- a/src/cron/service.heartbeat-ok-summary-suppressed.test.ts +++ b/src/cron/service.heartbeat-ok-summary-suppressed.test.ts @@ -86,7 +86,7 @@ describe("cron isolated job HEARTBEAT_OK summary suppression (#32013)", () => { expect(requestHeartbeatNow).not.toHaveBeenCalled(); }); - it("still enqueues real cron summaries as system events", async () => { + it("does not revive legacy main-session relay for real cron summaries", async () => { const { storePath } = await makeStorePath(); const now = Date.now(); @@ -109,10 +109,7 @@ describe("cron isolated job HEARTBEAT_OK summary suppression (#32013)", () => { await runScheduledCron(cron); - // Real summaries SHOULD be enqueued. - expect(enqueueSystemEvent).toHaveBeenCalledWith( - expect.stringContaining("Weather update"), - expect.objectContaining({ agentId: undefined }), - ); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); }); }); diff --git a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts index deac4a5b66833..555750bd73859 100644 --- a/src/cron/service.runs-one-shot-main-job-disables-it.test.ts +++ b/src/cron/service.runs-one-shot-main-job-disables-it.test.ts @@ -620,14 +620,14 @@ describe("CronService", () => { await stopCronAndCleanup(cron, store); }); - it("runs an isolated job and posts summary to main", async () => { + it("runs an isolated job without posting a fallback summary to main", async () => { const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" })); const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } = await createIsolatedAnnounceHarness(runIsolatedAgentJob); await runIsolatedAnnounceScenario({ cron, events, name: "weekly" }); expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); - expectMainSystemEventPosted(enqueueSystemEvent, "Cron: done"); - expect(requestHeartbeatNow).toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); await stopCronAndCleanup(cron, store); }); @@ -685,7 +685,7 @@ describe("CronService", () => { await stopCronAndCleanup(cron, store); }); - it("posts last output to main even when isolated job errors", async () => { + it("does not post a fallback main summary when an isolated job errors", async () => { const runIsolatedAgentJob = vi.fn(async () => ({ status: "error" as const, summary: "last output", @@ -700,8 +700,8 @@ describe("CronService", () => { status: "error", }); - expectMainSystemEventPosted(enqueueSystemEvent, "Cron (error): last output"); - expect(requestHeartbeatNow).toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestHeartbeatNow).not.toHaveBeenCalled(); await stopCronAndCleanup(cron, store); }); diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index 2c40ac506432e..d1d36e48e0811 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -1,161 +1,10 @@ import fs from "node:fs"; -import { normalizeLegacyDeliveryInput } from "../legacy-delivery.js"; -import { parseAbsoluteTimeMs } from "../parse.js"; -import { migrateLegacyCronPayload } from "../payload-migration.js"; -import { coerceFiniteScheduleNumber } from "../schedule.js"; -import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../stagger.js"; +import { normalizeStoredCronJobs } from "../store-migration.js"; import { loadCronStore, saveCronStore } from "../store.js"; import type { CronJob } from "../types.js"; import { recomputeNextRuns } from "./jobs.js"; -import { inferLegacyName, normalizeOptionalText } from "./normalize.js"; import type { CronServiceState } from "./state.js"; -function normalizePayloadKind(payload: Record) { - const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; - if (raw === "agentturn") { - payload.kind = "agentTurn"; - return true; - } - if (raw === "systemevent") { - payload.kind = "systemEvent"; - return true; - } - return false; -} - -function inferPayloadIfMissing(raw: Record) { - const message = typeof raw.message === "string" ? raw.message.trim() : ""; - const text = typeof raw.text === "string" ? raw.text.trim() : ""; - const command = typeof raw.command === "string" ? raw.command.trim() : ""; - if (message) { - raw.payload = { kind: "agentTurn", message }; - return true; - } - if (text) { - raw.payload = { kind: "systemEvent", text }; - return true; - } - if (command) { - raw.payload = { kind: "systemEvent", text: command }; - return true; - } - return false; -} - -function copyTopLevelAgentTurnFields( - raw: Record, - payload: Record, -) { - let mutated = false; - - const copyTrimmedString = (field: "model" | "thinking") => { - const existing = payload[field]; - if (typeof existing === "string" && existing.trim()) { - return; - } - const value = raw[field]; - if (typeof value === "string" && value.trim()) { - payload[field] = value.trim(); - mutated = true; - } - }; - copyTrimmedString("model"); - copyTrimmedString("thinking"); - - if ( - typeof payload.timeoutSeconds !== "number" && - typeof raw.timeoutSeconds === "number" && - Number.isFinite(raw.timeoutSeconds) - ) { - payload.timeoutSeconds = Math.max(0, Math.floor(raw.timeoutSeconds)); - mutated = true; - } - - if ( - typeof payload.allowUnsafeExternalContent !== "boolean" && - typeof raw.allowUnsafeExternalContent === "boolean" - ) { - payload.allowUnsafeExternalContent = raw.allowUnsafeExternalContent; - mutated = true; - } - - if (typeof payload.deliver !== "boolean" && typeof raw.deliver === "boolean") { - payload.deliver = raw.deliver; - mutated = true; - } - if ( - typeof payload.channel !== "string" && - typeof raw.channel === "string" && - raw.channel.trim() - ) { - payload.channel = raw.channel.trim(); - mutated = true; - } - if (typeof payload.to !== "string" && typeof raw.to === "string" && raw.to.trim()) { - payload.to = raw.to.trim(); - mutated = true; - } - if ( - typeof payload.bestEffortDeliver !== "boolean" && - typeof raw.bestEffortDeliver === "boolean" - ) { - payload.bestEffortDeliver = raw.bestEffortDeliver; - mutated = true; - } - if ( - typeof payload.provider !== "string" && - typeof raw.provider === "string" && - raw.provider.trim() - ) { - payload.provider = raw.provider.trim(); - mutated = true; - } - - return mutated; -} - -function stripLegacyTopLevelFields(raw: Record) { - if ("model" in raw) { - delete raw.model; - } - if ("thinking" in raw) { - delete raw.thinking; - } - if ("timeoutSeconds" in raw) { - delete raw.timeoutSeconds; - } - if ("allowUnsafeExternalContent" in raw) { - delete raw.allowUnsafeExternalContent; - } - if ("message" in raw) { - delete raw.message; - } - if ("text" in raw) { - delete raw.text; - } - if ("deliver" in raw) { - delete raw.deliver; - } - if ("channel" in raw) { - delete raw.channel; - } - if ("to" in raw) { - delete raw.to; - } - if ("bestEffortDeliver" in raw) { - delete raw.bestEffortDeliver; - } - if ("provider" in raw) { - delete raw.provider; - } - if ("command" in raw) { - delete raw.command; - } - if ("timeout" in raw) { - delete raw.timeout; - } -} - async function getFileMtimeMs(path: string): Promise { try { const stats = await fs.promises.stat(path); @@ -185,287 +34,7 @@ export async function ensureLoaded( const fileMtimeMs = await getFileMtimeMs(state.deps.storePath); const loaded = await loadCronStore(state.deps.storePath); const jobs = (loaded.jobs ?? []) as unknown as Array>; - let mutated = false; - for (const raw of jobs) { - const state = raw.state; - if (!state || typeof state !== "object" || Array.isArray(state)) { - raw.state = {}; - mutated = true; - } - - const rawId = typeof raw.id === "string" ? raw.id.trim() : ""; - const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : ""; - if (!rawId && legacyJobId) { - raw.id = legacyJobId; - mutated = true; - } else if (rawId && raw.id !== rawId) { - raw.id = rawId; - mutated = true; - } - if ("jobId" in raw) { - delete raw.jobId; - mutated = true; - } - - if (typeof raw.schedule === "string") { - const expr = raw.schedule.trim(); - raw.schedule = { kind: "cron", expr }; - mutated = true; - } - - const nameRaw = raw.name; - if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { - raw.name = inferLegacyName({ - schedule: raw.schedule as never, - payload: raw.payload as never, - }); - mutated = true; - } else { - raw.name = nameRaw.trim(); - } - - const desc = normalizeOptionalText(raw.description); - if (raw.description !== desc) { - raw.description = desc; - mutated = true; - } - - if ("sessionKey" in raw) { - const sessionKey = - typeof raw.sessionKey === "string" ? normalizeOptionalText(raw.sessionKey) : undefined; - if (raw.sessionKey !== sessionKey) { - raw.sessionKey = sessionKey; - mutated = true; - } - } - - if (typeof raw.enabled !== "boolean") { - raw.enabled = true; - mutated = true; - } - - const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : ""; - if (wakeModeRaw === "next-heartbeat") { - if (raw.wakeMode !== "next-heartbeat") { - raw.wakeMode = "next-heartbeat"; - mutated = true; - } - } else if (wakeModeRaw === "now") { - if (raw.wakeMode !== "now") { - raw.wakeMode = "now"; - mutated = true; - } - } else { - raw.wakeMode = "now"; - mutated = true; - } - - const payload = raw.payload; - if ( - (!payload || typeof payload !== "object" || Array.isArray(payload)) && - inferPayloadIfMissing(raw) - ) { - mutated = true; - } - - const payloadRecord = - raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) - ? (raw.payload as Record) - : null; - - if (payloadRecord) { - if (normalizePayloadKind(payloadRecord)) { - mutated = true; - } - if (!payloadRecord.kind) { - if (typeof payloadRecord.message === "string" && payloadRecord.message.trim()) { - payloadRecord.kind = "agentTurn"; - mutated = true; - } else if (typeof payloadRecord.text === "string" && payloadRecord.text.trim()) { - payloadRecord.kind = "systemEvent"; - mutated = true; - } - } - if (payloadRecord.kind === "agentTurn") { - if (copyTopLevelAgentTurnFields(raw, payloadRecord)) { - mutated = true; - } - } - } - - const hadLegacyTopLevelFields = - "model" in raw || - "thinking" in raw || - "timeoutSeconds" in raw || - "allowUnsafeExternalContent" in raw || - "message" in raw || - "text" in raw || - "deliver" in raw || - "channel" in raw || - "to" in raw || - "bestEffortDeliver" in raw || - "provider" in raw || - "command" in raw || - "timeout" in raw; - if (hadLegacyTopLevelFields) { - stripLegacyTopLevelFields(raw); - mutated = true; - } - - if (payloadRecord) { - if (migrateLegacyCronPayload(payloadRecord)) { - mutated = true; - } - } - - const schedule = raw.schedule; - if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) { - const sched = schedule as Record; - const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : ""; - if (!kind && ("at" in sched || "atMs" in sched)) { - sched.kind = "at"; - mutated = true; - } - const atRaw = typeof sched.at === "string" ? sched.at.trim() : ""; - const atMsRaw = sched.atMs; - const parsedAtMs = - typeof atMsRaw === "number" - ? atMsRaw - : typeof atMsRaw === "string" - ? parseAbsoluteTimeMs(atMsRaw) - : atRaw - ? parseAbsoluteTimeMs(atRaw) - : null; - if (parsedAtMs !== null) { - sched.at = new Date(parsedAtMs).toISOString(); - if ("atMs" in sched) { - delete sched.atMs; - } - mutated = true; - } - - const everyMsRaw = sched.everyMs; - const everyMsCoerced = coerceFiniteScheduleNumber(everyMsRaw); - const everyMs = everyMsCoerced !== undefined ? Math.floor(everyMsCoerced) : null; - if (everyMs !== null && everyMsRaw !== everyMs) { - sched.everyMs = everyMs; - mutated = true; - } - if ((kind === "every" || sched.kind === "every") && everyMs !== null) { - const anchorRaw = sched.anchorMs; - const anchorCoerced = coerceFiniteScheduleNumber(anchorRaw); - const normalizedAnchor = - anchorCoerced !== undefined - ? Math.max(0, Math.floor(anchorCoerced)) - : typeof raw.createdAtMs === "number" && Number.isFinite(raw.createdAtMs) - ? Math.max(0, Math.floor(raw.createdAtMs)) - : typeof raw.updatedAtMs === "number" && Number.isFinite(raw.updatedAtMs) - ? Math.max(0, Math.floor(raw.updatedAtMs)) - : null; - if (normalizedAnchor !== null && anchorRaw !== normalizedAnchor) { - sched.anchorMs = normalizedAnchor; - mutated = true; - } - } - - const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : ""; - const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : ""; - let normalizedExpr = exprRaw; - if (!normalizedExpr && legacyCronRaw) { - normalizedExpr = legacyCronRaw; - sched.expr = normalizedExpr; - mutated = true; - } - if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) { - sched.expr = normalizedExpr; - mutated = true; - } - if ("cron" in sched) { - delete sched.cron; - mutated = true; - } - if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) { - const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs); - const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr); - const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs; - if (targetStaggerMs === undefined) { - if ("staggerMs" in sched) { - delete sched.staggerMs; - mutated = true; - } - } else if (sched.staggerMs !== targetStaggerMs) { - sched.staggerMs = targetStaggerMs; - mutated = true; - } - } - } - - const delivery = raw.delivery; - if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) { - const modeRaw = (delivery as { mode?: unknown }).mode; - if (typeof modeRaw === "string") { - const lowered = modeRaw.trim().toLowerCase(); - if (lowered === "deliver") { - (delivery as { mode?: unknown }).mode = "announce"; - mutated = true; - } - } else if (modeRaw === undefined || modeRaw === null) { - // Explicitly persist the default so existing jobs don't silently - // change behaviour when the runtime default shifts. - (delivery as { mode?: unknown }).mode = "announce"; - mutated = true; - } - } - - const isolation = raw.isolation; - if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) { - delete raw.isolation; - mutated = true; - } - - const payloadKind = - payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; - const normalizedSessionTarget = - typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; - if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { - if (raw.sessionTarget !== normalizedSessionTarget) { - raw.sessionTarget = normalizedSessionTarget; - mutated = true; - } - } else { - const inferredSessionTarget = payloadKind === "agentTurn" ? "isolated" : "main"; - if (raw.sessionTarget !== inferredSessionTarget) { - raw.sessionTarget = inferredSessionTarget; - mutated = true; - } - } - - const sessionTarget = - typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; - const isIsolatedAgentTurn = - sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); - const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); - const normalizedLegacy = normalizeLegacyDeliveryInput({ - delivery: hasDelivery ? (delivery as Record) : null, - payload: payloadRecord, - }); - - if (isIsolatedAgentTurn && payloadKind === "agentTurn") { - if (!hasDelivery && normalizedLegacy.delivery) { - raw.delivery = normalizedLegacy.delivery; - mutated = true; - } else if (!hasDelivery) { - raw.delivery = { mode: "announce" }; - mutated = true; - } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { - raw.delivery = normalizedLegacy.delivery; - mutated = true; - } - } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { - raw.delivery = normalizedLegacy.delivery; - mutated = true; - } - } + const { mutated } = normalizeStoredCronJobs(jobs); state.store = { version: 1, jobs: jobs as unknown as CronJob[] }; state.storeLoadedAtMs = state.deps.nowMs(); state.storeFileMtimeMs = fileMtimeMs; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index f82290006b41a..5320ffdf526de 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,9 +1,7 @@ import type { CronConfig, CronRetryOn } from "../../config/types.cron.js"; -import { isCronSystemEvent } from "../../infra/heartbeat-events-filter.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; import { resolveCronDeliveryPlan } from "../delivery.js"; -import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; import { sweepCronRunSessions } from "../session-reaper.js"; import type { CronDeliveryStatus, @@ -1138,46 +1136,6 @@ export async function executeJobCore( return { status: "error", error: timeoutErrorMessage() }; } - // Post a short summary back to the main session only when announce - // delivery was requested and we are confident no outbound delivery path - // ran. If delivery was attempted but final ack is uncertain, suppress the - // main summary to avoid duplicate user-facing sends. - // See: https://github.com/openclaw/openclaw/issues/15692 - // - // Also suppress heartbeat-only summaries (e.g. "HEARTBEAT_OK") — these - // are internal ack tokens that should never leak into user conversations. - // See: https://github.com/openclaw/openclaw/issues/32013 - const summaryText = res.summary?.trim(); - const deliveryPlan = resolveCronDeliveryPlan(job); - const suppressMainSummary = - res.status === "error" && res.errorKind === "delivery-target" && deliveryPlan.requested; - if ( - shouldEnqueueCronMainSummary({ - summaryText, - deliveryRequested: deliveryPlan.requested, - delivered: res.delivered, - deliveryAttempted: res.deliveryAttempted, - suppressMainSummary, - isCronSystemEvent, - }) - ) { - const prefix = "Cron"; - const label = - res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`; - state.deps.enqueueSystemEvent(label, { - agentId: job.agentId, - sessionKey: job.sessionKey, - contextKey: `cron:${job.id}`, - }); - if (job.wakeMode === "now") { - state.deps.requestHeartbeatNow({ - reason: `cron:${job.id}`, - agentId: job.agentId, - sessionKey: job.sessionKey, - }); - } - } - return { status: res.status, error: res.error, diff --git a/src/cron/store-migration.test.ts b/src/cron/store-migration.test.ts new file mode 100644 index 0000000000000..79f3314c01996 --- /dev/null +++ b/src/cron/store-migration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { normalizeStoredCronJobs } from "./store-migration.js"; + +describe("normalizeStoredCronJobs", () => { + it("normalizes legacy cron fields and reports migration issues", () => { + const jobs = [ + { + jobId: "legacy-job", + schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" }, + message: "say hi", + model: "openai/gpt-4.1", + deliver: true, + provider: " TeLeGrAm ", + to: "12345", + }, + ] as Array>; + + const result = normalizeStoredCronJobs(jobs); + + expect(result.mutated).toBe(true); + expect(result.issues).toMatchObject({ + jobId: 1, + legacyScheduleCron: 1, + legacyTopLevelPayloadFields: 1, + legacyTopLevelDeliveryFields: 1, + }); + + const [job] = jobs; + expect(job?.jobId).toBeUndefined(); + expect(job?.id).toBe("legacy-job"); + expect(job?.schedule).toMatchObject({ + kind: "cron", + expr: "*/5 * * * *", + tz: "UTC", + }); + expect(job?.message).toBeUndefined(); + expect(job?.provider).toBeUndefined(); + expect(job?.delivery).toMatchObject({ + mode: "announce", + channel: "telegram", + to: "12345", + }); + expect(job?.payload).toMatchObject({ + kind: "agentTurn", + message: "say hi", + model: "openai/gpt-4.1", + }); + }); + + it("normalizes payload provider alias into channel", () => { + const jobs = [ + { + id: "legacy-provider", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + provider: " Slack ", + }, + }, + ] as Array>; + + const result = normalizeStoredCronJobs(jobs); + + expect(result.mutated).toBe(true); + expect(result.issues.legacyPayloadProvider).toBe(1); + expect(jobs[0]?.payload).toMatchObject({ + kind: "agentTurn", + message: "ping", + }); + const payload = jobs[0]?.payload as Record | undefined; + expect(payload?.provider).toBeUndefined(); + expect(jobs[0]?.delivery).toMatchObject({ + mode: "announce", + channel: "slack", + }); + }); +}); diff --git a/src/cron/store-migration.ts b/src/cron/store-migration.ts new file mode 100644 index 0000000000000..11789422e614d --- /dev/null +++ b/src/cron/store-migration.ts @@ -0,0 +1,491 @@ +import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js"; +import { parseAbsoluteTimeMs } from "./parse.js"; +import { migrateLegacyCronPayload } from "./payload-migration.js"; +import { coerceFiniteScheduleNumber } from "./schedule.js"; +import { inferLegacyName, normalizeOptionalText } from "./service/normalize.js"; +import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "./stagger.js"; + +type CronStoreIssueKey = + | "jobId" + | "legacyScheduleString" + | "legacyScheduleCron" + | "legacyPayloadKind" + | "legacyPayloadProvider" + | "legacyTopLevelPayloadFields" + | "legacyTopLevelDeliveryFields" + | "legacyDeliveryMode"; + +type CronStoreIssues = Partial>; + +type NormalizeCronStoreJobsResult = { + issues: CronStoreIssues; + jobs: Array>; + mutated: boolean; +}; + +function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) { + issues[key] = (issues[key] ?? 0) + 1; +} + +function normalizePayloadKind(payload: Record) { + const raw = typeof payload.kind === "string" ? payload.kind.trim().toLowerCase() : ""; + if (raw === "agentturn") { + payload.kind = "agentTurn"; + return true; + } + if (raw === "systemevent") { + payload.kind = "systemEvent"; + return true; + } + return false; +} + +function inferPayloadIfMissing(raw: Record) { + const message = typeof raw.message === "string" ? raw.message.trim() : ""; + const text = typeof raw.text === "string" ? raw.text.trim() : ""; + const command = typeof raw.command === "string" ? raw.command.trim() : ""; + if (message) { + raw.payload = { kind: "agentTurn", message }; + return true; + } + if (text) { + raw.payload = { kind: "systemEvent", text }; + return true; + } + if (command) { + raw.payload = { kind: "systemEvent", text: command }; + return true; + } + return false; +} + +function copyTopLevelAgentTurnFields( + raw: Record, + payload: Record, +) { + let mutated = false; + + const copyTrimmedString = (field: "model" | "thinking") => { + const existing = payload[field]; + if (typeof existing === "string" && existing.trim()) { + return; + } + const value = raw[field]; + if (typeof value === "string" && value.trim()) { + payload[field] = value.trim(); + mutated = true; + } + }; + copyTrimmedString("model"); + copyTrimmedString("thinking"); + + if ( + typeof payload.timeoutSeconds !== "number" && + typeof raw.timeoutSeconds === "number" && + Number.isFinite(raw.timeoutSeconds) + ) { + payload.timeoutSeconds = Math.max(0, Math.floor(raw.timeoutSeconds)); + mutated = true; + } + + if ( + typeof payload.allowUnsafeExternalContent !== "boolean" && + typeof raw.allowUnsafeExternalContent === "boolean" + ) { + payload.allowUnsafeExternalContent = raw.allowUnsafeExternalContent; + mutated = true; + } + + if (typeof payload.deliver !== "boolean" && typeof raw.deliver === "boolean") { + payload.deliver = raw.deliver; + mutated = true; + } + if ( + typeof payload.channel !== "string" && + typeof raw.channel === "string" && + raw.channel.trim() + ) { + payload.channel = raw.channel.trim(); + mutated = true; + } + if (typeof payload.to !== "string" && typeof raw.to === "string" && raw.to.trim()) { + payload.to = raw.to.trim(); + mutated = true; + } + if ( + typeof payload.bestEffortDeliver !== "boolean" && + typeof raw.bestEffortDeliver === "boolean" + ) { + payload.bestEffortDeliver = raw.bestEffortDeliver; + mutated = true; + } + if ( + typeof payload.provider !== "string" && + typeof raw.provider === "string" && + raw.provider.trim() + ) { + payload.provider = raw.provider.trim(); + mutated = true; + } + + return mutated; +} + +function stripLegacyTopLevelFields(raw: Record) { + if ("model" in raw) { + delete raw.model; + } + if ("thinking" in raw) { + delete raw.thinking; + } + if ("timeoutSeconds" in raw) { + delete raw.timeoutSeconds; + } + if ("allowUnsafeExternalContent" in raw) { + delete raw.allowUnsafeExternalContent; + } + if ("message" in raw) { + delete raw.message; + } + if ("text" in raw) { + delete raw.text; + } + if ("deliver" in raw) { + delete raw.deliver; + } + if ("channel" in raw) { + delete raw.channel; + } + if ("to" in raw) { + delete raw.to; + } + if ("bestEffortDeliver" in raw) { + delete raw.bestEffortDeliver; + } + if ("provider" in raw) { + delete raw.provider; + } + if ("command" in raw) { + delete raw.command; + } + if ("timeout" in raw) { + delete raw.timeout; + } +} + +export function normalizeStoredCronJobs( + jobs: Array>, +): NormalizeCronStoreJobsResult { + const issues: CronStoreIssues = {}; + let mutated = false; + + for (const raw of jobs) { + const jobIssues = new Set(); + const trackIssue = (key: CronStoreIssueKey) => { + if (jobIssues.has(key)) { + return; + } + jobIssues.add(key); + incrementIssue(issues, key); + }; + + const state = raw.state; + if (!state || typeof state !== "object" || Array.isArray(state)) { + raw.state = {}; + mutated = true; + } + + const rawId = typeof raw.id === "string" ? raw.id.trim() : ""; + const legacyJobId = typeof raw.jobId === "string" ? raw.jobId.trim() : ""; + if (!rawId && legacyJobId) { + raw.id = legacyJobId; + mutated = true; + trackIssue("jobId"); + } else if (rawId && raw.id !== rawId) { + raw.id = rawId; + mutated = true; + } + if ("jobId" in raw) { + delete raw.jobId; + mutated = true; + trackIssue("jobId"); + } + + if (typeof raw.schedule === "string") { + const expr = raw.schedule.trim(); + raw.schedule = { kind: "cron", expr }; + mutated = true; + trackIssue("legacyScheduleString"); + } + + const nameRaw = raw.name; + if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { + raw.name = inferLegacyName({ + schedule: raw.schedule as never, + payload: raw.payload as never, + }); + mutated = true; + } else { + raw.name = nameRaw.trim(); + } + + const desc = normalizeOptionalText(raw.description); + if (raw.description !== desc) { + raw.description = desc; + mutated = true; + } + + if ("sessionKey" in raw) { + const sessionKey = + typeof raw.sessionKey === "string" ? normalizeOptionalText(raw.sessionKey) : undefined; + if (raw.sessionKey !== sessionKey) { + raw.sessionKey = sessionKey; + mutated = true; + } + } + + if (typeof raw.enabled !== "boolean") { + raw.enabled = true; + mutated = true; + } + + const wakeModeRaw = typeof raw.wakeMode === "string" ? raw.wakeMode.trim().toLowerCase() : ""; + if (wakeModeRaw === "next-heartbeat") { + if (raw.wakeMode !== "next-heartbeat") { + raw.wakeMode = "next-heartbeat"; + mutated = true; + } + } else if (wakeModeRaw === "now") { + if (raw.wakeMode !== "now") { + raw.wakeMode = "now"; + mutated = true; + } + } else { + raw.wakeMode = "now"; + mutated = true; + } + + const payload = raw.payload; + if ( + (!payload || typeof payload !== "object" || Array.isArray(payload)) && + inferPayloadIfMissing(raw) + ) { + mutated = true; + trackIssue("legacyTopLevelPayloadFields"); + } + + const payloadRecord = + raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) + ? (raw.payload as Record) + : null; + + if (payloadRecord) { + if (normalizePayloadKind(payloadRecord)) { + mutated = true; + trackIssue("legacyPayloadKind"); + } + if (!payloadRecord.kind) { + if (typeof payloadRecord.message === "string" && payloadRecord.message.trim()) { + payloadRecord.kind = "agentTurn"; + mutated = true; + trackIssue("legacyPayloadKind"); + } else if (typeof payloadRecord.text === "string" && payloadRecord.text.trim()) { + payloadRecord.kind = "systemEvent"; + mutated = true; + trackIssue("legacyPayloadKind"); + } + } + if (payloadRecord.kind === "agentTurn" && copyTopLevelAgentTurnFields(raw, payloadRecord)) { + mutated = true; + } + } + + const hadLegacyTopLevelPayloadFields = + "model" in raw || + "thinking" in raw || + "timeoutSeconds" in raw || + "allowUnsafeExternalContent" in raw || + "message" in raw || + "text" in raw || + "command" in raw || + "timeout" in raw; + const hadLegacyTopLevelDeliveryFields = + "deliver" in raw || + "channel" in raw || + "to" in raw || + "bestEffortDeliver" in raw || + "provider" in raw; + if (hadLegacyTopLevelPayloadFields || hadLegacyTopLevelDeliveryFields) { + stripLegacyTopLevelFields(raw); + mutated = true; + if (hadLegacyTopLevelPayloadFields) { + trackIssue("legacyTopLevelPayloadFields"); + } + if (hadLegacyTopLevelDeliveryFields) { + trackIssue("legacyTopLevelDeliveryFields"); + } + } + + if (payloadRecord) { + const hadLegacyPayloadProvider = + typeof payloadRecord.provider === "string" && payloadRecord.provider.trim().length > 0; + if (migrateLegacyCronPayload(payloadRecord)) { + mutated = true; + if (hadLegacyPayloadProvider) { + trackIssue("legacyPayloadProvider"); + } + } + } + + const schedule = raw.schedule; + if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) { + const sched = schedule as Record; + const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : ""; + if (!kind && ("at" in sched || "atMs" in sched)) { + sched.kind = "at"; + mutated = true; + } + const atRaw = typeof sched.at === "string" ? sched.at.trim() : ""; + const atMsRaw = sched.atMs; + const parsedAtMs = + typeof atMsRaw === "number" + ? atMsRaw + : typeof atMsRaw === "string" + ? parseAbsoluteTimeMs(atMsRaw) + : atRaw + ? parseAbsoluteTimeMs(atRaw) + : null; + if (parsedAtMs !== null) { + sched.at = new Date(parsedAtMs).toISOString(); + if ("atMs" in sched) { + delete sched.atMs; + } + mutated = true; + } + + const everyMsRaw = sched.everyMs; + const everyMsCoerced = coerceFiniteScheduleNumber(everyMsRaw); + const everyMs = everyMsCoerced !== undefined ? Math.floor(everyMsCoerced) : null; + if (everyMs !== null && everyMsRaw !== everyMs) { + sched.everyMs = everyMs; + mutated = true; + } + if ((kind === "every" || sched.kind === "every") && everyMs !== null) { + const anchorRaw = sched.anchorMs; + const anchorCoerced = coerceFiniteScheduleNumber(anchorRaw); + const normalizedAnchor = + anchorCoerced !== undefined + ? Math.max(0, Math.floor(anchorCoerced)) + : typeof raw.createdAtMs === "number" && Number.isFinite(raw.createdAtMs) + ? Math.max(0, Math.floor(raw.createdAtMs)) + : typeof raw.updatedAtMs === "number" && Number.isFinite(raw.updatedAtMs) + ? Math.max(0, Math.floor(raw.updatedAtMs)) + : null; + if (normalizedAnchor !== null && anchorRaw !== normalizedAnchor) { + sched.anchorMs = normalizedAnchor; + mutated = true; + } + } + + const exprRaw = typeof sched.expr === "string" ? sched.expr.trim() : ""; + const legacyCronRaw = typeof sched.cron === "string" ? sched.cron.trim() : ""; + let normalizedExpr = exprRaw; + if (!normalizedExpr && legacyCronRaw) { + normalizedExpr = legacyCronRaw; + sched.expr = normalizedExpr; + mutated = true; + trackIssue("legacyScheduleCron"); + } + if (typeof sched.expr === "string" && sched.expr !== normalizedExpr) { + sched.expr = normalizedExpr; + mutated = true; + } + if ("cron" in sched) { + delete sched.cron; + mutated = true; + trackIssue("legacyScheduleCron"); + } + if ((kind === "cron" || sched.kind === "cron") && normalizedExpr) { + const explicitStaggerMs = normalizeCronStaggerMs(sched.staggerMs); + const defaultStaggerMs = resolveDefaultCronStaggerMs(normalizedExpr); + const targetStaggerMs = explicitStaggerMs ?? defaultStaggerMs; + if (targetStaggerMs === undefined) { + if ("staggerMs" in sched) { + delete sched.staggerMs; + mutated = true; + } + } else if (sched.staggerMs !== targetStaggerMs) { + sched.staggerMs = targetStaggerMs; + mutated = true; + } + } + } + + const delivery = raw.delivery; + if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) { + const modeRaw = (delivery as { mode?: unknown }).mode; + if (typeof modeRaw === "string") { + const lowered = modeRaw.trim().toLowerCase(); + if (lowered === "deliver") { + (delivery as { mode?: unknown }).mode = "announce"; + mutated = true; + trackIssue("legacyDeliveryMode"); + } + } else if (modeRaw === undefined || modeRaw === null) { + (delivery as { mode?: unknown }).mode = "announce"; + mutated = true; + } + } + + const isolation = raw.isolation; + if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) { + delete raw.isolation; + mutated = true; + } + + const payloadKind = + payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : ""; + const normalizedSessionTarget = + typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; + if (normalizedSessionTarget === "main" || normalizedSessionTarget === "isolated") { + if (raw.sessionTarget !== normalizedSessionTarget) { + raw.sessionTarget = normalizedSessionTarget; + mutated = true; + } + } else { + const inferredSessionTarget = payloadKind === "agentTurn" ? "isolated" : "main"; + if (raw.sessionTarget !== inferredSessionTarget) { + raw.sessionTarget = inferredSessionTarget; + mutated = true; + } + } + + const sessionTarget = + typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : ""; + const isIsolatedAgentTurn = + sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn"); + const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery); + const normalizedLegacy = normalizeLegacyDeliveryInput({ + delivery: hasDelivery ? (delivery as Record) : null, + payload: payloadRecord, + }); + + if (isIsolatedAgentTurn && payloadKind === "agentTurn") { + if (!hasDelivery && normalizedLegacy.delivery) { + raw.delivery = normalizedLegacy.delivery; + mutated = true; + } else if (!hasDelivery) { + raw.delivery = { mode: "announce" }; + mutated = true; + } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { + raw.delivery = normalizedLegacy.delivery; + mutated = true; + } + } else if (normalizedLegacy.mutated && normalizedLegacy.delivery) { + raw.delivery = normalizedLegacy.delivery; + mutated = true; + } + } + + return { issues, jobs, mutated }; +} diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index ccaf54412372c..2590f63c23d2b 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -848,6 +848,32 @@ describe("gateway server cron", () => { 'Cron job "failure destination webhook" failed: unknown error', ); + fetchWithSsrFGuardMock.mockClear(); + cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "best-effort failed" }); + const bestEffortFailureDestJobId = await addWebhookCronJob({ + ws, + name: "best effort failure destination webhook", + sessionTarget: "isolated", + delivery: { + mode: "announce", + channel: "telegram", + to: "19098680", + bestEffort: true, + failureDestination: { + mode: "webhook", + to: "https://example.invalid/failure-destination", + }, + }, + }); + const bestEffortFailureDestFinished = waitForCronEvent( + ws, + (payload) => + payload?.jobId === bestEffortFailureDestJobId && payload?.action === "finished", + ); + await runCronJobForce(ws, bestEffortFailureDestJobId); + await bestEffortFailureDestFinished; + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" }); const noSummaryJobId = await addWebhookCronJob({ ws, @@ -861,7 +887,7 @@ describe("gateway server cron", () => { ); await runCronJobForce(ws, noSummaryJobId); await noSummaryFinished; - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); } finally { await cleanupCronTestRun({ ws, server, prevSkipCron }); } diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 6711671e4ee59..2a4e1c961a01b 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -75,6 +75,10 @@ describe("gateway server hooks", () => { expect(resAgent.status).toBe(200); const agentEvents = await waitForSystemEvent(); expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true); + const firstCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as { + deliveryContract?: string; + }; + expect(firstCall?.deliveryContract).toBe("shared"); drainSystemEvents(resolveMainKey()); mockIsolatedRunOkOnce(); diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 3b294be8fb9e6..3b159c680afac 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -76,6 +76,7 @@ export function createGatewayHooksRequestHandler(params: { message: value.message, sessionKey, lane: "cron", + deliveryContract: "shared", }); const summary = result.summary?.trim() || result.error?.trim() || result.status; const prefix = From 87d939be793675952d50de4722b8f5ee6434d001 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 9 Mar 2026 22:27:05 +0300 Subject: [PATCH 276/311] Agents: add embedded error observations (#41336) Merged via squash. Prepared head SHA: 490004229862129ceb21939e382658714e23bd68 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../pi-embedded-error-observation.test.ts | 182 ++++++++++++++++ src/agents/pi-embedded-error-observation.ts | 199 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 41 +++- .../run/failover-observation.test.ts | 48 +++++ .../run/failover-observation.ts | 76 +++++++ ...edded-subscribe.handlers.lifecycle.test.ts | 62 +++++- ...i-embedded-subscribe.handlers.lifecycle.ts | 32 ++- .../pi-embedded-subscribe.handlers.types.ts | 4 +- 9 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 src/agents/pi-embedded-error-observation.test.ts create mode 100644 src/agents/pi-embedded-error-observation.ts create mode 100644 src/agents/pi-embedded-runner/run/failover-observation.test.ts create mode 100644 src/agents/pi-embedded-runner/run/failover-observation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be8bad0eaa7b..028a09e896c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026. - ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn. - ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. +- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. ## 2026.3.8 diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts new file mode 100644 index 0000000000000..94979ebfb8cd4 --- /dev/null +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as loggingConfigModule from "../logging/config.js"; +import { + buildApiErrorObservationFields, + buildTextObservationFields, + sanitizeForConsole, +} from "./pi-embedded-error-observation.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildApiErrorObservationFields", () => { + it("redacts request ids and exposes stable hashes instead of raw payloads", () => { + const observed = buildApiErrorObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}', + ); + + expect(observed).toMatchObject({ + rawErrorPreview: expect.stringContaining('"request_id":"sha256:'), + rawErrorHash: expect.stringMatching(/^sha256:/), + rawErrorFingerprint: expect.stringMatching(/^sha256:/), + providerErrorType: "overloaded_error", + providerErrorMessagePreview: "Overloaded", + requestIdHash: expect.stringMatching(/^sha256:/), + }); + expect(observed.rawErrorPreview).not.toContain("req_overload"); + }); + + it("forces token redaction for observation previews", () => { + const observed = buildApiErrorObservationFields( + "Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456", + ); + + expect(observed.rawErrorPreview).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).toContain("sk-abc"); + expect(observed.rawErrorHash).toMatch(/^sha256:/); + }); + + it("redacts observation-only header and cookie formats", () => { + const observed = buildApiErrorObservationFields( + "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456 Cookie: session=abcdefghijklmnopqrstuvwxyz123456", + ); + + expect(observed.rawErrorPreview).not.toContain("abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).toContain("x-api-key: ***"); + expect(observed.rawErrorPreview).toContain("Cookie: session="); + }); + + it("does not let cookie redaction consume unrelated fields on the same line", () => { + const observed = buildApiErrorObservationFields( + "Cookie: session=abcdefghijklmnopqrstuvwxyz123456 status=503 request_id=req_cookie", + ); + + expect(observed.rawErrorPreview).toContain("Cookie: session="); + expect(observed.rawErrorPreview).toContain("status=503"); + expect(observed.rawErrorPreview).toContain("request_id=sha256:"); + }); + + it("builds sanitized generic text observation fields", () => { + const observed = buildTextObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_prev"}', + ); + + expect(observed).toMatchObject({ + textPreview: expect.stringContaining('"request_id":"sha256:'), + textHash: expect.stringMatching(/^sha256:/), + textFingerprint: expect.stringMatching(/^sha256:/), + providerErrorType: "overloaded_error", + providerErrorMessagePreview: "Overloaded", + requestIdHash: expect.stringMatching(/^sha256:/), + }); + expect(observed.textPreview).not.toContain("req_prev"); + }); + + it("redacts request ids in formatted plain-text errors", () => { + const observed = buildApiErrorObservationFields( + "LLM error overloaded_error: Overloaded (request_id: req_plaintext_123)", + ); + + expect(observed).toMatchObject({ + rawErrorPreview: expect.stringContaining("request_id: sha256:"), + rawErrorFingerprint: expect.stringMatching(/^sha256:/), + requestIdHash: expect.stringMatching(/^sha256:/), + }); + expect(observed.rawErrorPreview).not.toContain("req_plaintext_123"); + }); + + it("keeps fingerprints stable across request ids for equivalent errors", () => { + const first = buildApiErrorObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_001"}', + ); + const second = buildApiErrorObservationFields( + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_002"}', + ); + + expect(first.rawErrorFingerprint).toBe(second.rawErrorFingerprint); + expect(first.rawErrorHash).not.toBe(second.rawErrorHash); + }); + + it("truncates oversized raw and provider previews", () => { + const longMessage = "X".repeat(260); + const observed = buildApiErrorObservationFields( + `{"type":"error","error":{"type":"server_error","message":"${longMessage}"},"request_id":"req_long"}`, + ); + + expect(observed.rawErrorPreview).toBeDefined(); + expect(observed.providerErrorMessagePreview).toBeDefined(); + expect(observed.rawErrorPreview?.length).toBeLessThanOrEqual(401); + expect(observed.providerErrorMessagePreview?.length).toBeLessThanOrEqual(201); + expect(observed.providerErrorMessagePreview?.endsWith("…")).toBe(true); + }); + + it("caps oversized raw inputs before hashing and fingerprinting", () => { + const oversized = "X".repeat(70_000); + const bounded = "X".repeat(64_000); + + expect(buildApiErrorObservationFields(oversized)).toMatchObject({ + rawErrorHash: buildApiErrorObservationFields(bounded).rawErrorHash, + rawErrorFingerprint: buildApiErrorObservationFields(bounded).rawErrorFingerprint, + }); + }); + + it("returns empty observation fields for empty input", () => { + expect(buildApiErrorObservationFields(undefined)).toEqual({}); + expect(buildApiErrorObservationFields("")).toEqual({}); + expect(buildApiErrorObservationFields(" ")).toEqual({}); + }); + + it("re-reads configured redact patterns on each call", () => { + const readLoggingConfig = vi.spyOn(loggingConfigModule, "readLoggingConfig"); + readLoggingConfig.mockReturnValueOnce(undefined); + readLoggingConfig.mockReturnValueOnce({ + redactPatterns: [String.raw`\bcustom-secret-[A-Za-z0-9]+\b`], + }); + + const first = buildApiErrorObservationFields("custom-secret-abc123"); + const second = buildApiErrorObservationFields("custom-secret-abc123"); + + expect(first.rawErrorPreview).toContain("custom-secret-abc123"); + expect(second.rawErrorPreview).not.toContain("custom-secret-abc123"); + expect(second.rawErrorPreview).toContain("custom"); + }); + + it("fails closed when observation sanitization throws", () => { + vi.spyOn(loggingConfigModule, "readLoggingConfig").mockImplementation(() => { + throw new Error("boom"); + }); + + expect(buildApiErrorObservationFields("request_id=req_123")).toEqual({}); + expect(buildTextObservationFields("request_id=req_123")).toEqual({ + textPreview: undefined, + textHash: undefined, + textFingerprint: undefined, + httpCode: undefined, + providerErrorType: undefined, + providerErrorMessagePreview: undefined, + requestIdHash: undefined, + }); + }); + + it("ignores non-string configured redact patterns", () => { + vi.spyOn(loggingConfigModule, "readLoggingConfig").mockReturnValue({ + redactPatterns: [ + 123 as never, + { bad: true } as never, + String.raw`\bcustom-secret-[A-Za-z0-9]+\b`, + ], + }); + + const observed = buildApiErrorObservationFields("custom-secret-abc123"); + + expect(observed.rawErrorPreview).not.toContain("custom-secret-abc123"); + expect(observed.rawErrorPreview).toContain("custom"); + }); +}); + +describe("sanitizeForConsole", () => { + it("strips control characters from console-facing values", () => { + expect(sanitizeForConsole("run-1\nprovider\tmodel\rtest")).toBe("run-1 provider model test"); + }); +}); diff --git a/src/agents/pi-embedded-error-observation.ts b/src/agents/pi-embedded-error-observation.ts new file mode 100644 index 0000000000000..260bf83f4c5ef --- /dev/null +++ b/src/agents/pi-embedded-error-observation.ts @@ -0,0 +1,199 @@ +import { readLoggingConfig } from "../logging/config.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; +import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js"; +import { getApiErrorPayloadFingerprint, parseApiErrorInfo } from "./pi-embedded-helpers.js"; +import { stableStringify } from "./stable-stringify.js"; + +const MAX_OBSERVATION_INPUT_CHARS = 64_000; +const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000; +const RAW_ERROR_PREVIEW_MAX_CHARS = 400; +const PROVIDER_ERROR_PREVIEW_MAX_CHARS = 200; +const REQUEST_ID_RE = /\brequest[_ ]?id\b\s*[:=]\s*["'()]*([A-Za-z0-9._:-]+)/i; +const OBSERVATION_EXTRA_REDACT_PATTERNS = [ + String.raw`\b(?:x-)?api[-_]?key\b\s*[:=]\s*(["']?)([^\s"'\\;]+)\1`, + String.raw`"(?:api[-_]?key|api_key)"\s*:\s*"([^"]+)"`, + String.raw`(?:\bCookie\b\s*[:=]\s*[^;=\s]+=|;\s*[^;=\s]+=)([^;\s\r\n]+)`, +]; + +function resolveConfiguredRedactPatterns(): string[] { + const configured = readLoggingConfig()?.redactPatterns; + if (!Array.isArray(configured)) { + return []; + } + return configured.filter((pattern): pattern is string => typeof pattern === "string"); +} + +function truncateForObservation(text: string | undefined, maxChars: number): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}…` : trimmed; +} + +function boundObservationInput(text: string | undefined): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.length > MAX_OBSERVATION_INPUT_CHARS + ? trimmed.slice(0, MAX_OBSERVATION_INPUT_CHARS) + : trimmed; +} + +export function sanitizeForConsole(text: string | undefined, maxChars = 200): string | undefined { + const trimmed = text?.trim(); + if (!trimmed) { + return undefined; + } + const withoutControlChars = Array.from(trimmed) + .filter((char) => { + const code = char.charCodeAt(0); + return !( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ); + }) + .join(""); + const sanitized = withoutControlChars + .replace(/[\r\n\t]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return sanitized.length > maxChars ? `${sanitized.slice(0, maxChars)}…` : sanitized; +} + +function replaceRequestIdPreview( + text: string | undefined, + requestId: string | undefined, +): string | undefined { + if (!text || !requestId) { + return text; + } + return text.split(requestId).join(redactIdentifier(requestId, { len: 12 })); +} + +function redactObservationText(text: string | undefined): string | undefined { + if (!text) { + return text; + } + // Observation logs must stay redacted even when operators disable general-purpose + // log redaction, otherwise raw provider payloads leak back into always-on logs. + const configuredPatterns = resolveConfiguredRedactPatterns(); + return redactSensitiveText(text, { + mode: "tools", + patterns: [ + ...getDefaultRedactPatterns(), + ...configuredPatterns, + ...OBSERVATION_EXTRA_REDACT_PATTERNS, + ], + }); +} + +function extractRequestId(text: string | undefined): string | undefined { + if (!text) { + return undefined; + } + const match = text.match(REQUEST_ID_RE); + return match?.[1]?.trim() || undefined; +} + +function buildObservationFingerprint(params: { + raw: string; + requestId?: string; + httpCode?: string; + type?: string; + message?: string; +}): string | null { + const boundedMessage = + params.message && params.message.length > MAX_FINGERPRINT_MESSAGE_CHARS + ? params.message.slice(0, MAX_FINGERPRINT_MESSAGE_CHARS) + : params.message; + const structured = + params.httpCode || params.type || boundedMessage + ? stableStringify({ + httpCode: params.httpCode, + type: params.type, + message: boundedMessage, + }) + : null; + if (structured) { + return structured; + } + if (params.requestId) { + return params.raw.split(params.requestId).join(""); + } + return getApiErrorPayloadFingerprint(params.raw); +} + +export function buildApiErrorObservationFields(rawError?: string): { + rawErrorPreview?: string; + rawErrorHash?: string; + rawErrorFingerprint?: string; + httpCode?: string; + providerErrorType?: string; + providerErrorMessagePreview?: string; + requestIdHash?: string; +} { + const trimmed = boundObservationInput(rawError); + if (!trimmed) { + return {}; + } + try { + const parsed = parseApiErrorInfo(trimmed); + const requestId = parsed?.requestId ?? extractRequestId(trimmed); + const requestIdHash = requestId ? redactIdentifier(requestId, { len: 12 }) : undefined; + const rawFingerprint = buildObservationFingerprint({ + raw: trimmed, + requestId, + httpCode: parsed?.httpCode, + type: parsed?.type, + message: parsed?.message, + }); + const redactedRawPreview = replaceRequestIdPreview(redactObservationText(trimmed), requestId); + const redactedProviderMessage = replaceRequestIdPreview( + redactObservationText(parsed?.message), + requestId, + ); + + return { + rawErrorPreview: truncateForObservation(redactedRawPreview, RAW_ERROR_PREVIEW_MAX_CHARS), + rawErrorHash: redactIdentifier(trimmed, { len: 12 }), + rawErrorFingerprint: rawFingerprint + ? redactIdentifier(rawFingerprint, { len: 12 }) + : undefined, + httpCode: parsed?.httpCode, + providerErrorType: parsed?.type, + providerErrorMessagePreview: truncateForObservation( + redactedProviderMessage, + PROVIDER_ERROR_PREVIEW_MAX_CHARS, + ), + requestIdHash, + }; + } catch { + return {}; + } +} + +export function buildTextObservationFields(text?: string): { + textPreview?: string; + textHash?: string; + textFingerprint?: string; + httpCode?: string; + providerErrorType?: string; + providerErrorMessagePreview?: string; + requestIdHash?: string; +} { + const observed = buildApiErrorObservationFields(text); + return { + textPreview: observed.rawErrorPreview, + textHash: observed.rawErrorHash, + textFingerprint: observed.rawErrorFingerprint, + httpCode: observed.httpCode, + providerErrorType: observed.providerErrorType, + providerErrorMessagePreview: observed.providerErrorMessagePreview, + requestIdHash: observed.requestIdHash, + }; +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 21b29fe2cb6fb..68677a009bd63 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -61,6 +61,7 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModel } from "./model.js"; import { runEmbeddedAttempt } from "./run/attempt.js"; +import { createFailoverDecisionLogger } from "./run/failover-observation.js"; import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { @@ -1226,11 +1227,26 @@ export async function runEmbeddedPiAgent( reason: promptProfileFailureReason, }); const promptFailoverFailure = isFailoverErrorMessage(errorText); + // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. + const failedPromptProfileId = lastProfileId; + const logPromptFailoverDecision = createFailoverDecisionLogger({ + stage: "prompt", + runId: params.runId, + rawError: errorText, + failoverReason: promptFailoverReason, + profileFailureReason: promptProfileFailureReason, + provider, + model: modelId, + profileId: failedPromptProfileId, + fallbackConfigured, + aborted, + }); if ( promptFailoverFailure && promptFailoverReason !== "timeout" && (await advanceAuthProfile()) ) { + logPromptFailoverDecision("rotate_profile"); await maybeBackoffBeforeOverloadFailover(promptFailoverReason); continue; } @@ -1249,15 +1265,20 @@ export async function runEmbeddedPiAgent( // are configured so outer model fallback can continue on overload, // rate-limit, auth, or billing failures. if (fallbackConfigured && promptFailoverFailure) { + const status = resolveFailoverStatus(promptFailoverReason ?? "unknown"); + logPromptFailoverDecision("fallback_model", { status }); await maybeBackoffBeforeOverloadFailover(promptFailoverReason); throw new FailoverError(errorText, { reason: promptFailoverReason ?? "unknown", provider, model: modelId, profileId: lastProfileId, - status: resolveFailoverStatus(promptFailoverReason ?? "unknown"), + status, }); } + if (promptFailoverFailure || promptFailoverReason) { + logPromptFailoverDecision("surface_error"); + } throw promptError; } @@ -1282,6 +1303,21 @@ export async function runEmbeddedPiAgent( resolveAuthProfileFailureReason(assistantFailoverReason); const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError; const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? ""); + // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. + const failedAssistantProfileId = lastProfileId; + const logAssistantFailoverDecision = createFailoverDecisionLogger({ + stage: "assistant", + runId: params.runId, + rawError: lastAssistant?.errorMessage?.trim(), + failoverReason: assistantFailoverReason, + profileFailureReason: assistantProfileFailureReason, + provider: activeErrorContext.provider, + model: activeErrorContext.model, + profileId: failedAssistantProfileId, + fallbackConfigured, + timedOut, + aborted, + }); if ( authFailure && @@ -1339,6 +1375,7 @@ export async function runEmbeddedPiAgent( const rotated = await advanceAuthProfile(); if (rotated) { + logAssistantFailoverDecision("rotate_profile"); await maybeBackoffBeforeOverloadFailover(assistantFailoverReason); continue; } @@ -1371,6 +1408,7 @@ export async function runEmbeddedPiAgent( const status = resolveFailoverStatus(assistantFailoverReason ?? "unknown") ?? (isTimeoutErrorMessage(message) ? 408 : undefined); + logAssistantFailoverDecision("fallback_model", { status }); throw new FailoverError(message, { reason: assistantFailoverReason ?? "unknown", provider: activeErrorContext.provider, @@ -1379,6 +1417,7 @@ export async function runEmbeddedPiAgent( status, }); } + logAssistantFailoverDecision("surface_error"); } const usage = toNormalizedUsage(usageAccumulator); diff --git a/src/agents/pi-embedded-runner/run/failover-observation.test.ts b/src/agents/pi-embedded-runner/run/failover-observation.test.ts new file mode 100644 index 0000000000000..763540f9ca7e9 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/failover-observation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { normalizeFailoverDecisionObservationBase } from "./failover-observation.js"; + +describe("normalizeFailoverDecisionObservationBase", () => { + it("fills timeout observation reasons for deadline timeouts without provider error text", () => { + expect( + normalizeFailoverDecisionObservationBase({ + stage: "assistant", + runId: "run:timeout", + rawError: "", + failoverReason: null, + profileFailureReason: null, + provider: "openai", + model: "mock-1", + profileId: "openai:p1", + fallbackConfigured: false, + timedOut: true, + aborted: false, + }), + ).toMatchObject({ + failoverReason: "timeout", + profileFailureReason: "timeout", + timedOut: true, + }); + }); + + it("preserves explicit failover reasons", () => { + expect( + normalizeFailoverDecisionObservationBase({ + stage: "assistant", + runId: "run:overloaded", + rawError: '{"error":{"type":"overloaded_error"}}', + failoverReason: "overloaded", + profileFailureReason: "overloaded", + provider: "openai", + model: "mock-1", + profileId: "openai:p1", + fallbackConfigured: true, + timedOut: true, + aborted: false, + }), + ).toMatchObject({ + failoverReason: "overloaded", + profileFailureReason: "overloaded", + timedOut: true, + }); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/failover-observation.ts b/src/agents/pi-embedded-runner/run/failover-observation.ts new file mode 100644 index 0000000000000..9b9155353146d --- /dev/null +++ b/src/agents/pi-embedded-runner/run/failover-observation.ts @@ -0,0 +1,76 @@ +import { redactIdentifier } from "../../../logging/redact-identifier.js"; +import type { AuthProfileFailureReason } from "../../auth-profiles.js"; +import { + buildApiErrorObservationFields, + sanitizeForConsole, +} from "../../pi-embedded-error-observation.js"; +import type { FailoverReason } from "../../pi-embedded-helpers.js"; +import { log } from "../logger.js"; + +export type FailoverDecisionLoggerInput = { + stage: "prompt" | "assistant"; + decision: "rotate_profile" | "fallback_model" | "surface_error"; + runId?: string; + rawError?: string; + failoverReason: FailoverReason | null; + profileFailureReason?: AuthProfileFailureReason | null; + provider: string; + model: string; + profileId?: string; + fallbackConfigured: boolean; + timedOut?: boolean; + aborted?: boolean; + status?: number; +}; + +export type FailoverDecisionLoggerBase = Omit; + +export function normalizeFailoverDecisionObservationBase( + base: FailoverDecisionLoggerBase, +): FailoverDecisionLoggerBase { + return { + ...base, + failoverReason: base.failoverReason ?? (base.timedOut ? "timeout" : null), + profileFailureReason: base.profileFailureReason ?? (base.timedOut ? "timeout" : null), + }; +} + +export function createFailoverDecisionLogger( + base: FailoverDecisionLoggerBase, +): ( + decision: FailoverDecisionLoggerInput["decision"], + extra?: Pick, +) => void { + const normalizedBase = normalizeFailoverDecisionObservationBase(base); + const safeProfileId = normalizedBase.profileId + ? redactIdentifier(normalizedBase.profileId, { len: 12 }) + : undefined; + const safeRunId = sanitizeForConsole(normalizedBase.runId) ?? "-"; + const safeProvider = sanitizeForConsole(normalizedBase.provider) ?? "-"; + const safeModel = sanitizeForConsole(normalizedBase.model) ?? "-"; + const profileText = safeProfileId ?? "-"; + const reasonText = normalizedBase.failoverReason ?? "none"; + return (decision, extra) => { + const observedError = buildApiErrorObservationFields(normalizedBase.rawError); + log.warn("embedded run failover decision", { + event: "embedded_run_failover_decision", + tags: ["error_handling", "failover", normalizedBase.stage, decision], + runId: normalizedBase.runId, + stage: normalizedBase.stage, + decision, + failoverReason: normalizedBase.failoverReason, + profileFailureReason: normalizedBase.profileFailureReason, + provider: normalizedBase.provider, + model: normalizedBase.model, + profileId: safeProfileId, + fallbackConfigured: normalizedBase.fallbackConfigured, + timedOut: normalizedBase.timedOut, + aborted: normalizedBase.aborted, + status: extra?.status, + ...observedError, + consoleMessage: + `embedded run failover decision: runId=${safeRunId} stage=${normalizedBase.stage} decision=${decision} ` + + `reason=${reasonText} provider=${safeProvider}/${safeModel} profile=${profileText}`, + }); + }; +} diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index 7a8b1e12e0515..b93cf43cebe5b 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -54,8 +54,13 @@ describe("handleAgentEnd", () => { const warn = vi.mocked(ctx.log.warn); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain("runId=run-1"); - expect(warn.mock.calls[0]?.[0]).toContain("error=connection refused"); + expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end"); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + event: "embedded_run_agent_end", + runId: "run-1", + error: "connection refused", + rawErrorPreview: "connection refused", + }); expect(onAgentEvent).toHaveBeenCalledWith({ stream: "lifecycle", data: { @@ -65,6 +70,59 @@ describe("handleAgentEnd", () => { }); }); + it("attaches raw provider error metadata without changing the console message", () => { + const ctx = createContext({ + role: "assistant", + stopReason: "error", + provider: "anthropic", + model: "claude-test", + errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + content: [{ type: "text", text: "" }], + }); + + handleAgentEnd(ctx); + + const warn = vi.mocked(ctx.log.warn); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toBe("embedded run agent end"); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + event: "embedded_run_agent_end", + runId: "run-1", + error: "The AI service is temporarily overloaded. Please try again in a moment.", + failoverReason: "overloaded", + providerErrorType: "overloaded_error", + }); + }); + + it("redacts logged error text before emitting lifecycle events", () => { + const onAgentEvent = vi.fn(); + const ctx = createContext( + { + role: "assistant", + stopReason: "error", + errorMessage: "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456", + content: [{ type: "text", text: "" }], + }, + { onAgentEvent }, + ); + + handleAgentEnd(ctx); + + const warn = vi.mocked(ctx.log.warn); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + event: "embedded_run_agent_end", + error: "x-api-key: ***", + rawErrorPreview: "x-api-key: ***", + }); + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "lifecycle", + data: { + phase: "error", + error: "x-api-key: ***", + }, + }); + }); + it("keeps non-error run-end logging on debug only", () => { const ctx = createContext(undefined); diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 4c6803e814cb9..c666784ff8e96 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -1,6 +1,11 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import { createInlineCodeState } from "../markdown/code-spans.js"; -import { formatAssistantErrorText } from "./pi-embedded-helpers.js"; +import { + buildApiErrorObservationFields, + buildTextObservationFields, + sanitizeForConsole, +} from "./pi-embedded-error-observation.js"; +import { classifyFailoverReason, formatAssistantErrorText } from "./pi-embedded-helpers.js"; import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js"; import { isAssistantMessage } from "./pi-embedded-utils.js"; @@ -36,16 +41,31 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { provider: lastAssistant.provider, model: lastAssistant.model, }); + const rawError = lastAssistant.errorMessage?.trim(); + const failoverReason = classifyFailoverReason(rawError ?? ""); const errorText = (friendlyError || lastAssistant.errorMessage || "LLM request failed.").trim(); - ctx.log.warn( - `embedded run agent end: runId=${ctx.params.runId} isError=true error=${errorText}`, - ); + const observedError = buildApiErrorObservationFields(rawError); + const safeErrorText = + buildTextObservationFields(errorText).textPreview ?? "LLM request failed."; + const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; + ctx.log.warn("embedded run agent end", { + event: "embedded_run_agent_end", + tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], + runId: ctx.params.runId, + isError: true, + error: safeErrorText, + failoverReason, + provider: lastAssistant.provider, + model: lastAssistant.model, + ...observedError, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true error=${safeErrorText}`, + }); emitAgentEvent({ runId: ctx.params.runId, stream: "lifecycle", data: { phase: "error", - error: errorText, + error: safeErrorText, endedAt: Date.now(), }, }); @@ -53,7 +73,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { stream: "lifecycle", data: { phase: "error", - error: errorText, + error: safeErrorText, }, }); } else { diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 1a9d48f46f030..955af473b9e91 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -12,8 +12,8 @@ import type { import type { NormalizedUsage } from "./usage.js"; export type EmbeddedSubscribeLogger = { - debug: (message: string) => void; - warn: (message: string) => void; + debug: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; }; export type ToolErrorSummary = { From d86647d7dbcbde03f549490450f148d159785161 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 12:35:31 -0700 Subject: [PATCH 277/311] Doctor: fix non-interactive cron repair gating (#41386) --- src/commands/doctor-cron.test.ts | 111 +++++++++++++++++++++++++++++++ src/commands/doctor-cron.ts | 13 ++-- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 8c9faf0e24d5d..e7af38f662c25 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -155,4 +155,115 @@ describe("maybeRepairLegacyCronStore", () => { "Doctor warnings", ); }); + + it("does not auto-repair in non-interactive mode without explicit repair approval", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + jobId: "legacy-job", + name: "Legacy job", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "cron", cron: "0 7 * * *", tz: "UTC" }, + payload: { + kind: "systemEvent", + text: "Morning brief", + }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + const prompter = makePrompter(false); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + options: { nonInteractive: true }, + prompter, + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); + expect(persisted.jobs[0]?.jobId).toBe("legacy-job"); + expect(persisted.jobs[0]?.notify).toBe(true); + expect(noteSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Cron store normalized"), + "Doctor changes", + ); + }); + + it("migrates notify fallback none delivery jobs to cron.webhook", async () => { + const storePath = await makeTempStorePath(); + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile( + storePath, + JSON.stringify( + { + version: 1, + jobs: [ + { + id: "notify-none", + name: "Notify none", + notify: true, + createdAtMs: Date.parse("2026-02-01T00:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-02T00:00:00.000Z"), + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "systemEvent", + text: "Status", + }, + delivery: { mode: "none", to: "123456789" }, + state: {}, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + await maybeRepairLegacyCronStore({ + cfg: { + cron: { + store: storePath, + webhook: "https://example.invalid/cron-finished", + }, + }, + options: {}, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.notify).toBeUndefined(); + expect(persisted.jobs[0]?.delivery).toMatchObject({ + mode: "webhook", + to: "https://example.invalid/cron-finished", + }); + }); }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index 3dc6275e80026..53963cb0d1480 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -96,7 +96,7 @@ function migrateLegacyNotifyFallback(params: { raw.delivery = { ...delivery, mode: "webhook", - to: to ?? params.legacyWebhook, + to: mode === "none" ? params.legacyWebhook : (to ?? params.legacyWebhook), }; delete raw.notify; changed = true; @@ -152,13 +152,10 @@ export async function maybeRepairLegacyCronStore(params: { "Cron", ); - const shouldRepair = - params.options.nonInteractive === true - ? true - : await params.prompter.confirm({ - message: "Repair legacy cron jobs now?", - initialValue: true, - }); + const shouldRepair = await params.prompter.confirm({ + message: "Repair legacy cron jobs now?", + initialValue: true, + }); if (!shouldRepair) { return; } From 0bcddb3d4f093a25d616e5f82a37b7c7d7cb038e Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:12:23 +0100 Subject: [PATCH 278/311] iOS: reconnect gateway on foreground return (#41384) Merged via squash. Prepared head SHA: 0e2e0dcc36fb90e92342430198f82f9594c8caf3 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + apps/ios/Sources/Model/NodeAppModel.swift | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 028a09e896c8b..25c4aed8ce956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn. - ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. - Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. +- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. ## 2026.3.8 diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e5a8c2161616d..4b9483e76624f 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -362,7 +362,14 @@ final class NodeAppModel { await MainActor.run { self.operatorConnected = false self.gatewayConnected = false + // Foreground recovery must actively restart the saved gateway config. + // Disconnecting stale sockets alone can leave us idle if the old + // reconnect tasks were suppressed or otherwise got stuck in background. + self.gatewayStatusText = "Reconnecting…" self.talkMode.updateGatewayConnected(false) + if let cfg = self.activeGatewayConnectConfig { + self.applyGatewayConnectConfig(cfg) + } } } } From 2b2e5e203823a9ad9a31aaf47b170c92b1d0467e Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Mon, 9 Mar 2026 21:16:28 +0100 Subject: [PATCH 279/311] fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement (#41401) * fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement When a cron task's agent returns NO_REPLY, the payload filter strips the silent token, leaving an empty text string. isLikelyInterimCronMessage() previously returned true for empty input, causing the cron runner to inject a forced rerun prompt ('Your previous response was only an acknowledgement...'). Change the empty-string branch to return false: empty text after payload filtering means the agent deliberately chose silent completion, not that it sent an interim 'on it' message. Fixes #41246 * fix(cron): do not misclassify empty/NO_REPLY as interim acknowledgement Fixes #41246. (#41383) thanks @jackal092927. --------- Co-authored-by: xaeon2026 --- CHANGELOG.md | 1 + src/cron/isolated-agent/subagent-followup.test.ts | 8 ++++++-- src/cron/isolated-agent/subagent-followup.ts | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c4aed8ce956..7c9c649d7f9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn. - Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. +- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. ## 2026.3.8 diff --git a/src/cron/isolated-agent/subagent-followup.test.ts b/src/cron/isolated-agent/subagent-followup.test.ts index 093da0100262c..c670e4c8c1362 100644 --- a/src/cron/isolated-agent/subagent-followup.test.ts +++ b/src/cron/isolated-agent/subagent-followup.test.ts @@ -47,8 +47,12 @@ describe("isLikelyInterimCronMessage", () => { false, ); }); - it("treats empty as interim", () => { - expect(isLikelyInterimCronMessage("")).toBe(true); + it("does not treat empty as interim (empty = NO_REPLY was stripped)", () => { + expect(isLikelyInterimCronMessage("")).toBe(false); + }); + + it("does not treat whitespace-only as interim", () => { + expect(isLikelyInterimCronMessage(" ")).toBe(false); }); }); diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index 6d5f9d4c5026b..9d6ec7e78aca5 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -42,7 +42,10 @@ function normalizeHintText(value: string): string { export function isLikelyInterimCronMessage(value: string): boolean { const normalized = normalizeHintText(value); if (!normalized) { - return true; + // Empty text after payload filtering means the agent either returned + // NO_REPLY (deliberately silent) or produced no deliverable content. + // Do not treat this as an interim acknowledgement that needs a rerun. + return false; } const words = normalized.split(" ").filter(Boolean).length; return words <= 45 && INTERIM_CRON_HINTS.some((hint) => normalized.includes(hint)); From 5f90883ad378920249160fe2d9c610c362be765c Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 10 Mar 2026 04:40:11 +0800 Subject: [PATCH 280/311] fix(auth): reset cooldown error counters on expiry to prevent infinite escalation (#41028) Merged via squash. Prepared head SHA: 89bd83f09a141f68c0cd715a3652559ad04be7c6 Co-authored-by: zerone0x <39543393+zerone0x@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + ...th-profiles.markauthprofilefailure.test.ts | 52 +++++++++++++++++++ src/agents/auth-profiles/usage.test.ts | 15 ++++-- src/agents/auth-profiles/usage.ts | 14 ++++- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9c649d7f9f1..d38c10cf6d6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. +- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. ## 2026.3.8 diff --git a/src/agents/auth-profiles.markauthprofilefailure.test.ts b/src/agents/auth-profiles.markauthprofilefailure.test.ts index e5690f75c6ad8..5c4d73197b3fe 100644 --- a/src/agents/auth-profiles.markauthprofilefailure.test.ts +++ b/src/agents/auth-profiles.markauthprofilefailure.test.ts @@ -190,6 +190,58 @@ describe("markAuthProfileFailure", () => { } }); + it("resets error count when previous cooldown has expired to prevent escalation", async () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-")); + try { + const authPath = path.join(agentDir, "auth-profiles.json"); + const now = Date.now(); + // Simulate state left on disk after 3 rapid failures within a 1-min cooldown + // window. The cooldown has since expired, but clearExpiredCooldowns() only + // ran in-memory and never persisted — so disk still carries errorCount: 3. + fs.writeFileSync( + authPath, + JSON.stringify({ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + }, + usageStats: { + "anthropic:default": { + errorCount: 3, + failureCounts: { rate_limit: 3 }, + lastFailureAt: now - 120_000, // 2 minutes ago + cooldownUntil: now - 60_000, // expired 1 minute ago + }, + }, + }), + ); + + const store = ensureAuthProfileStore(agentDir); + await markAuthProfileFailure({ + store, + profileId: "anthropic:default", + reason: "rate_limit", + agentDir, + }); + + const stats = store.usageStats?.["anthropic:default"]; + // Error count should reset to 1 (not escalate to 4) because the + // previous cooldown expired. Cooldown should be ~1 min, not ~60 min. + expect(stats?.errorCount).toBe(1); + expect(stats?.failureCounts?.rate_limit).toBe(1); + const cooldownMs = (stats?.cooldownUntil ?? 0) - now; + // calculateAuthProfileCooldownMs(1) = 60_000 (1 minute) + expect(cooldownMs).toBeLessThan(120_000); + expect(cooldownMs).toBeGreaterThan(0); + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); + } + }); + it("does not persist cooldown windows for OpenRouter profiles", async () => { await withAuthProfileStore(async ({ agentDir, store }) => { await markAuthProfileFailure({ diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index 120f75d366522..261eae6efd580 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -608,6 +608,10 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () }); } + // When a cooldown/disabled window expires, the error count resets to prevent + // stale counters from escalating the next cooldown (the root cause of + // infinite cooldown loops — see #40989). The next failure should compute + // backoff from errorCount=1, not from the accumulated stale count. const expiredWindowCases = [ { label: "cooldownUntil", @@ -617,7 +621,8 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () errorCount: 3, lastFailureAt: now - 60_000, }), - expectedUntil: (now: number) => now + 60 * 60 * 1000, + // errorCount resets → calculateAuthProfileCooldownMs(1) = 60_000 + expectedUntil: (now: number) => now + 60_000, readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil, }, { @@ -630,7 +635,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () failureCounts: { billing: 2 }, lastFailureAt: now - 60_000, }), - expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + // errorCount resets, billing count resets to 1 → + // calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h + expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000, readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, { @@ -643,7 +650,9 @@ describe("markAuthProfileFailure — active windows do not extend on retry", () failureCounts: { auth_permanent: 2 }, lastFailureAt: now - 60_000, }), - expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000, + // errorCount resets, auth_permanent count resets to 1 → + // calculateAuthProfileBillingDisableMsWithConfig(1, 5h, 24h) = 5h + expectedUntil: (now: number) => now + 5 * 60 * 60 * 1000, readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil, }, ]; diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index c28b51e3e575b..0d9ae6a6aaa9c 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -400,9 +400,19 @@ function computeNextProfileUsageStats(params: { params.existing.lastFailureAt > 0 && params.now - params.existing.lastFailureAt > windowMs; - const baseErrorCount = windowExpired ? 0 : (params.existing.errorCount ?? 0); + // If the previous cooldown has already expired, reset error counters so the + // profile gets a fresh backoff window. clearExpiredCooldowns() does this + // in-memory during profile ordering, but the on-disk state may still carry + // the old counters when the lock-based updater reads a fresh store. Without + // this check, stale error counts from an expired cooldown cause the next + // failure to escalate to a much longer cooldown (e.g. 1 min → 25 min). + const unusableUntil = resolveProfileUnusableUntil(params.existing); + const previousCooldownExpired = typeof unusableUntil === "number" && params.now >= unusableUntil; + + const shouldResetCounters = windowExpired || previousCooldownExpired; + const baseErrorCount = shouldResetCounters ? 0 : (params.existing.errorCount ?? 0); const nextErrorCount = baseErrorCount + 1; - const failureCounts = windowExpired ? {} : { ...params.existing.failureCounts }; + const failureCounts = shouldResetCounters ? {} : { ...params.existing.failureCounts }; failureCounts[params.reason] = (failureCounts[params.reason] ?? 0) + 1; const updatedStats: ProfileUsageStats = { From ef95975411a9a53084c91f6a123759eb42fb032c Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:42:57 +0100 Subject: [PATCH 281/311] Gateway: add pending node work primitives (#41409) Merged via squash. Prepared head SHA: a6d7ca90d71a33c6d634a6396d1e7ae40545ea66 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 2 + src/gateway/method-scopes.test.ts | 4 + src/gateway/method-scopes.ts | 2 + src/gateway/node-pending-work.test.ts | 46 +++++ src/gateway/node-pending-work.ts | 182 ++++++++++++++++++ src/gateway/protocol/index.ts | 22 +++ src/gateway/protocol/schema/nodes.ts | 58 ++++++ .../protocol/schema/protocol-schemas.ts | 8 + src/gateway/protocol/schema/types.ts | 4 + src/gateway/role-policy.test.ts | 2 + src/gateway/server-methods-list.ts | 2 + src/gateway/server-methods.ts | 2 + .../server-methods/nodes-pending.test.ts | 177 +++++++++++++++++ src/gateway/server-methods/nodes-pending.ts | 159 +++++++++++++++ src/gateway/server-methods/nodes.ts | 16 +- 15 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 src/gateway/node-pending-work.test.ts create mode 100644 src/gateway/node-pending-work.ts create mode 100644 src/gateway/server-methods/nodes-pending.test.ts create mode 100644 src/gateway/server-methods/nodes-pending.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d38c10cf6d6f4..7b25de7522da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky. + ### Breaking - Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky. diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 1479611d4846a..18ff74509eee1 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -18,6 +18,10 @@ describe("method scope resolution", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("poll")).toEqual(["operator.write"]); }); + it("leaves node-only pending drain outside operator scopes", () => { + expect(resolveLeastPrivilegeOperatorScopesForMethod("node.pending.drain")).toEqual([]); + }); + it("returns empty scopes for unknown methods", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("totally.unknown.method")).toEqual([]); }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 91b20baacb0c7..ec8279a1947b8 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -22,6 +22,7 @@ export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [ const NODE_ROLE_METHODS = new Set([ "node.invoke.result", "node.event", + "node.pending.drain", "node.canvas.capability.refresh", "node.pending.pull", "node.pending.ack", @@ -102,6 +103,7 @@ const METHOD_SCOPE_GROUPS: Record = { "chat.abort", "browser.request", "push.test", + "node.pending.enqueue", ], [ADMIN_SCOPE]: [ "channels.logout", diff --git a/src/gateway/node-pending-work.test.ts b/src/gateway/node-pending-work.test.ts new file mode 100644 index 0000000000000..3c2222dd3a931 --- /dev/null +++ b/src/gateway/node-pending-work.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + acknowledgeNodePendingWork, + drainNodePendingWork, + enqueueNodePendingWork, + resetNodePendingWorkForTests, +} from "./node-pending-work.js"; + +describe("node pending work", () => { + beforeEach(() => { + resetNodePendingWorkForTests(); + }); + + it("returns a baseline status request even when no explicit work is queued", () => { + const drained = drainNodePendingWork("node-1"); + expect(drained.items).toEqual([ + expect.objectContaining({ + id: "baseline-status", + type: "status.request", + priority: "default", + }), + ]); + expect(drained.hasMore).toBe(false); + }); + + it("dedupes explicit work by type and removes acknowledged items", () => { + const first = enqueueNodePendingWork({ nodeId: "node-2", type: "location.request" }); + const second = enqueueNodePendingWork({ nodeId: "node-2", type: "location.request" }); + + expect(first.deduped).toBe(false); + expect(second.deduped).toBe(true); + expect(second.item.id).toBe(first.item.id); + + const drained = drainNodePendingWork("node-2"); + expect(drained.items.map((item) => item.type)).toEqual(["location.request", "status.request"]); + + const acked = acknowledgeNodePendingWork({ + nodeId: "node-2", + itemIds: [first.item.id, "baseline-status"], + }); + expect(acked.removedItemIds).toEqual([first.item.id]); + + const afterAck = drainNodePendingWork("node-2"); + expect(afterAck.items.map((item) => item.id)).toEqual(["baseline-status"]); + }); +}); diff --git a/src/gateway/node-pending-work.ts b/src/gateway/node-pending-work.ts new file mode 100644 index 0000000000000..33d356777d259 --- /dev/null +++ b/src/gateway/node-pending-work.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; + +export const NODE_PENDING_WORK_TYPES = ["status.request", "location.request"] as const; +export type NodePendingWorkType = (typeof NODE_PENDING_WORK_TYPES)[number]; + +export const NODE_PENDING_WORK_PRIORITIES = ["default", "normal", "high"] as const; +export type NodePendingWorkPriority = (typeof NODE_PENDING_WORK_PRIORITIES)[number]; + +export type NodePendingWorkItem = { + id: string; + type: NodePendingWorkType; + priority: NodePendingWorkPriority; + createdAtMs: number; + expiresAtMs: number | null; + payload?: Record; +}; + +type NodePendingWorkState = { + revision: number; + itemsById: Map; +}; + +type DrainOptions = { + maxItems?: number; + includeDefaultStatus?: boolean; + nowMs?: number; +}; + +type DrainResult = { + revision: number; + items: NodePendingWorkItem[]; + hasMore: boolean; +}; + +const DEFAULT_STATUS_ITEM_ID = "baseline-status"; +const DEFAULT_STATUS_PRIORITY: NodePendingWorkPriority = "default"; +const DEFAULT_PRIORITY: NodePendingWorkPriority = "normal"; +const DEFAULT_MAX_ITEMS = 4; +const MAX_ITEMS = 10; +const PRIORITY_RANK: Record = { + high: 3, + normal: 2, + default: 1, +}; + +const stateByNodeId = new Map(); + +function getState(nodeId: string): NodePendingWorkState { + let state = stateByNodeId.get(nodeId); + if (!state) { + state = { + revision: 0, + itemsById: new Map(), + }; + stateByNodeId.set(nodeId, state); + } + return state; +} + +function pruneExpired(state: NodePendingWorkState, nowMs: number): boolean { + let changed = false; + for (const [id, item] of state.itemsById) { + if (item.expiresAtMs !== null && item.expiresAtMs <= nowMs) { + state.itemsById.delete(id); + changed = true; + } + } + if (changed) { + state.revision += 1; + } + return changed; +} + +function sortedItems(state: NodePendingWorkState): NodePendingWorkItem[] { + return [...state.itemsById.values()].toSorted((a, b) => { + const priorityDelta = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + if (a.createdAtMs !== b.createdAtMs) { + return a.createdAtMs - b.createdAtMs; + } + return a.id.localeCompare(b.id); + }); +} + +function makeBaselineStatusItem(nowMs: number): NodePendingWorkItem { + return { + id: DEFAULT_STATUS_ITEM_ID, + type: "status.request", + priority: DEFAULT_STATUS_PRIORITY, + createdAtMs: nowMs, + expiresAtMs: null, + }; +} + +export function enqueueNodePendingWork(params: { + nodeId: string; + type: NodePendingWorkType; + priority?: NodePendingWorkPriority; + expiresInMs?: number; + payload?: Record; +}): { revision: number; item: NodePendingWorkItem; deduped: boolean } { + const nodeId = params.nodeId.trim(); + if (!nodeId) { + throw new Error("nodeId required"); + } + const nowMs = Date.now(); + const state = getState(nodeId); + pruneExpired(state, nowMs); + const existing = [...state.itemsById.values()].find((item) => item.type === params.type); + if (existing) { + return { revision: state.revision, item: existing, deduped: true }; + } + const item: NodePendingWorkItem = { + id: randomUUID(), + type: params.type, + priority: params.priority ?? DEFAULT_PRIORITY, + createdAtMs: nowMs, + expiresAtMs: + typeof params.expiresInMs === "number" && Number.isFinite(params.expiresInMs) + ? nowMs + Math.max(1_000, Math.trunc(params.expiresInMs)) + : null, + ...(params.payload ? { payload: params.payload } : {}), + }; + state.itemsById.set(item.id, item); + state.revision += 1; + return { revision: state.revision, item, deduped: false }; +} + +export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): DrainResult { + const normalizedNodeId = nodeId.trim(); + if (!normalizedNodeId) { + return { revision: 0, items: [], hasMore: false }; + } + const nowMs = opts.nowMs ?? Date.now(); + const state = getState(normalizedNodeId); + pruneExpired(state, nowMs); + const maxItems = Math.min(MAX_ITEMS, Math.max(1, Math.trunc(opts.maxItems ?? DEFAULT_MAX_ITEMS))); + const explicitItems = sortedItems(state); + const items = explicitItems.slice(0, maxItems); + const hasExplicitStatus = explicitItems.some((item) => item.type === "status.request"); + const includeBaseline = opts.includeDefaultStatus !== false && !hasExplicitStatus; + if (includeBaseline && items.length < maxItems) { + items.push(makeBaselineStatusItem(nowMs)); + } + return { + revision: state.revision, + items, + hasMore: + explicitItems.length > items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length, + }; +} + +export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: string[] }): { + revision: number; + removedItemIds: string[]; +} { + const nodeId = params.nodeId.trim(); + if (!nodeId) { + return { revision: 0, removedItemIds: [] }; + } + const state = getState(nodeId); + const removedItemIds: string[] = []; + for (const itemId of params.itemIds) { + const trimmedId = itemId.trim(); + if (!trimmedId || trimmedId === DEFAULT_STATUS_ITEM_ID) { + continue; + } + if (state.itemsById.delete(trimmedId)) { + removedItemIds.push(trimmedId); + } + } + if (removedItemIds.length > 0) { + state.revision += 1; + } + return { revision: state.revision, removedItemIds }; +} + +export function resetNodePendingWorkForTests() { + stateByNodeId.clear(); +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 95306f27f1262..9c4693333638f 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -140,6 +140,14 @@ import { NodeDescribeParamsSchema, type NodeEventParams, NodeEventParamsSchema, + type NodePendingDrainParams, + NodePendingDrainParamsSchema, + type NodePendingDrainResult, + NodePendingDrainResultSchema, + type NodePendingEnqueueParams, + NodePendingEnqueueParamsSchema, + type NodePendingEnqueueResult, + NodePendingEnqueueResultSchema, type NodeInvokeParams, NodeInvokeParamsSchema, type NodeInvokeResultParams, @@ -296,6 +304,12 @@ export const validateNodeInvokeResultParams = ajv.compile(NodeEventParamsSchema); +export const validateNodePendingDrainParams = ajv.compile( + NodePendingDrainParamsSchema, +); +export const validateNodePendingEnqueueParams = ajv.compile( + NodePendingEnqueueParamsSchema, +); export const validatePushTestParams = ajv.compile(PushTestParamsSchema); export const validateSecretsResolveParams = ajv.compile( SecretsResolveParamsSchema, @@ -472,6 +486,10 @@ export { NodeListParamsSchema, NodePendingAckParamsSchema, NodeInvokeParamsSchema, + NodePendingDrainParamsSchema, + NodePendingDrainResultSchema, + NodePendingEnqueueParamsSchema, + NodePendingEnqueueResultSchema, SessionsListParamsSchema, SessionsPreviewParamsSchema, SessionsPatchParamsSchema, @@ -621,6 +639,10 @@ export type { NodeInvokeParams, NodeInvokeResultParams, NodeEventParams, + NodePendingDrainParams, + NodePendingDrainResult, + NodePendingEnqueueParams, + NodePendingEnqueueResult, SessionsListParams, SessionsPreviewParams, SessionsResolveParams, diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index 7ce5a4fed0ae0..413bd42fa4226 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -1,6 +1,14 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString } from "./primitives.js"; +const NodePendingWorkTypeSchema = Type.String({ + enum: ["status.request", "location.request"], +}); + +const NodePendingWorkPrioritySchema = Type.String({ + enum: ["normal", "high"], +}); + export const NodePairRequestParamsSchema = Type.Object( { nodeId: NonEmptyString, @@ -95,6 +103,56 @@ export const NodeEventParamsSchema = Type.Object( { additionalProperties: false }, ); +export const NodePendingDrainParamsSchema = Type.Object( + { + maxItems: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })), + }, + { additionalProperties: false }, +); + +export const NodePendingDrainItemSchema = Type.Object( + { + id: NonEmptyString, + type: NodePendingWorkTypeSchema, + priority: Type.String({ enum: ["default", "normal", "high"] }), + createdAtMs: Type.Integer({ minimum: 0 }), + expiresAtMs: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])), + payload: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + }, + { additionalProperties: false }, +); + +export const NodePendingDrainResultSchema = Type.Object( + { + nodeId: NonEmptyString, + revision: Type.Integer({ minimum: 0 }), + items: Type.Array(NodePendingDrainItemSchema), + hasMore: Type.Boolean(), + }, + { additionalProperties: false }, +); + +export const NodePendingEnqueueParamsSchema = Type.Object( + { + nodeId: NonEmptyString, + type: NodePendingWorkTypeSchema, + priority: Type.Optional(NodePendingWorkPrioritySchema), + expiresInMs: Type.Optional(Type.Integer({ minimum: 1_000, maximum: 86_400_000 })), + wake: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const NodePendingEnqueueResultSchema = Type.Object( + { + nodeId: NonEmptyString, + revision: Type.Integer({ minimum: 0 }), + queued: NodePendingDrainItemSchema, + wakeTriggered: Type.Boolean(), + }, + { additionalProperties: false }, +); + export const NodeInvokeRequestEventSchema = Type.Object( { id: NonEmptyString, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 7ccd6cb2d1a06..574a74d8d41e9 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -114,6 +114,10 @@ import { import { NodeDescribeParamsSchema, NodeEventParamsSchema, + NodePendingDrainParamsSchema, + NodePendingDrainResultSchema, + NodePendingEnqueueParamsSchema, + NodePendingEnqueueResultSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeInvokeRequestEventSchema, @@ -186,6 +190,10 @@ export const ProtocolSchemas = { NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeResultParams: NodeInvokeResultParamsSchema, NodeEventParams: NodeEventParamsSchema, + NodePendingDrainParams: NodePendingDrainParamsSchema, + NodePendingDrainResult: NodePendingDrainResultSchema, + NodePendingEnqueueParams: NodePendingEnqueueParamsSchema, + NodePendingEnqueueResult: NodePendingEnqueueResultSchema, NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, PushTestParams: PushTestParamsSchema, PushTestResult: PushTestResultSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index cc15b80fd1a6b..56656aff1a357 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -32,6 +32,10 @@ export type NodeDescribeParams = SchemaType<"NodeDescribeParams">; export type NodeInvokeParams = SchemaType<"NodeInvokeParams">; export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">; export type NodeEventParams = SchemaType<"NodeEventParams">; +export type NodePendingDrainParams = SchemaType<"NodePendingDrainParams">; +export type NodePendingDrainResult = SchemaType<"NodePendingDrainResult">; +export type NodePendingEnqueueParams = SchemaType<"NodePendingEnqueueParams">; +export type NodePendingEnqueueResult = SchemaType<"NodePendingEnqueueResult">; export type PushTestParams = SchemaType<"PushTestParams">; export type PushTestResult = SchemaType<"PushTestResult">; export type SessionsListParams = SchemaType<"SessionsListParams">; diff --git a/src/gateway/role-policy.test.ts b/src/gateway/role-policy.test.ts index ba371b56bfea9..5bc3e1f1a2829 100644 --- a/src/gateway/role-policy.test.ts +++ b/src/gateway/role-policy.test.ts @@ -21,8 +21,10 @@ describe("gateway role policy", () => { test("authorizes roles against node vs operator methods", () => { expect(isRoleAuthorizedForMethod("node", "node.event")).toBe(true); + expect(isRoleAuthorizedForMethod("node", "node.pending.drain")).toBe(true); expect(isRoleAuthorizedForMethod("node", "status")).toBe(false); expect(isRoleAuthorizedForMethod("operator", "status")).toBe(true); + expect(isRoleAuthorizedForMethod("operator", "node.pending.drain")).toBe(false); expect(isRoleAuthorizedForMethod("operator", "node.event")).toBe(false); }); }); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 5c5433ae2f7d8..2785eb7957eaa 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -76,6 +76,8 @@ const BASE_METHODS = [ "node.rename", "node.list", "node.describe", + "node.pending.drain", + "node.pending.enqueue", "node.invoke", "node.pending.pull", "node.pending.ack", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 62cd6bbcd9e3c..483914b9bf519 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -18,6 +18,7 @@ import { execApprovalsHandlers } from "./server-methods/exec-approvals.js"; import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; import { modelsHandlers } from "./server-methods/models.js"; +import { nodePendingHandlers } from "./server-methods/nodes-pending.js"; import { nodeHandlers } from "./server-methods/nodes.js"; import { pushHandlers } from "./server-methods/push.js"; import { sendHandlers } from "./server-methods/send.js"; @@ -87,6 +88,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...systemHandlers, ...updateHandlers, ...nodeHandlers, + ...nodePendingHandlers, ...pushHandlers, ...sendHandlers, ...usageHandlers, diff --git a/src/gateway/server-methods/nodes-pending.test.ts b/src/gateway/server-methods/nodes-pending.test.ts new file mode 100644 index 0000000000000..110ef8711e4b4 --- /dev/null +++ b/src/gateway/server-methods/nodes-pending.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { nodePendingHandlers } from "./nodes-pending.js"; + +const mocks = vi.hoisted(() => ({ + drainNodePendingWork: vi.fn(), + enqueueNodePendingWork: vi.fn(), + maybeWakeNodeWithApns: vi.fn(), + maybeSendNodeWakeNudge: vi.fn(), + waitForNodeReconnect: vi.fn(), +})); + +vi.mock("../node-pending-work.js", () => ({ + drainNodePendingWork: mocks.drainNodePendingWork, + enqueueNodePendingWork: mocks.enqueueNodePendingWork, +})); + +vi.mock("./nodes.js", () => ({ + NODE_WAKE_RECONNECT_WAIT_MS: 3_000, + NODE_WAKE_RECONNECT_RETRY_WAIT_MS: 12_000, + maybeWakeNodeWithApns: mocks.maybeWakeNodeWithApns, + maybeSendNodeWakeNudge: mocks.maybeSendNodeWakeNudge, + waitForNodeReconnect: mocks.waitForNodeReconnect, +})); + +type RespondCall = [ + boolean, + unknown?, + { + code?: number; + message?: string; + details?: unknown; + }?, +]; + +function makeContext(overrides?: Partial>) { + return { + nodeRegistry: { + get: vi.fn(() => undefined), + }, + logGateway: { + info: vi.fn(), + warn: vi.fn(), + }, + ...overrides, + }; +} + +describe("node.pending handlers", () => { + beforeEach(() => { + mocks.drainNodePendingWork.mockReset(); + mocks.enqueueNodePendingWork.mockReset(); + mocks.maybeWakeNodeWithApns.mockReset(); + mocks.maybeSendNodeWakeNudge.mockReset(); + mocks.waitForNodeReconnect.mockReset(); + }); + + it("drains pending work for the connected node identity", async () => { + mocks.drainNodePendingWork.mockReturnValue({ + revision: 2, + items: [{ id: "baseline-status", type: "status.request", priority: "default" }], + hasMore: false, + }); + const respond = vi.fn(); + + await nodePendingHandlers["node.pending.drain"]({ + params: { maxItems: 3 }, + respond: respond as never, + client: { connect: { device: { id: "ios-node-1" } } } as never, + context: makeContext() as never, + req: { type: "req", id: "req-node-pending-drain", method: "node.pending.drain" }, + isWebchatConnect: () => false, + }); + + expect(mocks.drainNodePendingWork).toHaveBeenCalledWith("ios-node-1", { + maxItems: 3, + includeDefaultStatus: true, + }); + expect(respond).toHaveBeenCalledWith( + true, + { + nodeId: "ios-node-1", + revision: 2, + items: [{ id: "baseline-status", type: "status.request", priority: "default" }], + hasMore: false, + }, + undefined, + ); + }); + + it("rejects node.pending.drain without a connected device identity", async () => { + const respond = vi.fn(); + + await nodePendingHandlers["node.pending.drain"]({ + params: {}, + respond: respond as never, + client: null, + context: makeContext() as never, + req: { type: "req", id: "req-node-pending-drain-missing", method: "node.pending.drain" }, + isWebchatConnect: () => false, + }); + + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.message).toContain("connected device identity"); + }); + + it("enqueues pending work and wakes a disconnected node once", async () => { + mocks.enqueueNodePendingWork.mockReturnValue({ + revision: 4, + deduped: false, + item: { + id: "pending-1", + type: "location.request", + priority: "high", + createdAtMs: 100, + expiresAtMs: null, + }, + }); + mocks.maybeWakeNodeWithApns.mockResolvedValue({ + available: true, + throttled: false, + path: "apns", + durationMs: 12, + apnsStatus: 200, + apnsReason: null, + }); + let connected = false; + mocks.waitForNodeReconnect.mockImplementation(async () => { + connected = true; + return true; + }); + const context = makeContext({ + nodeRegistry: { + get: vi.fn(() => (connected ? { nodeId: "ios-node-2" } : undefined)), + }, + }); + const respond = vi.fn(); + + await nodePendingHandlers["node.pending.enqueue"]({ + params: { + nodeId: "ios-node-2", + type: "location.request", + priority: "high", + }, + respond: respond as never, + client: null, + context: context as never, + req: { type: "req", id: "req-node-pending-enqueue", method: "node.pending.enqueue" }, + isWebchatConnect: () => false, + }); + + expect(mocks.enqueueNodePendingWork).toHaveBeenCalledWith({ + nodeId: "ios-node-2", + type: "location.request", + priority: "high", + expiresInMs: undefined, + }); + expect(mocks.maybeWakeNodeWithApns).toHaveBeenCalledWith("ios-node-2", { + wakeReason: "node.pending", + }); + expect(mocks.waitForNodeReconnect).toHaveBeenCalledWith({ + nodeId: "ios-node-2", + context, + timeoutMs: 3_000, + }); + expect(mocks.maybeSendNodeWakeNudge).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + nodeId: "ios-node-2", + revision: 4, + wakeTriggered: true, + }), + undefined, + ); + }); +}); diff --git a/src/gateway/server-methods/nodes-pending.ts b/src/gateway/server-methods/nodes-pending.ts new file mode 100644 index 0000000000000..8c46951b07241 --- /dev/null +++ b/src/gateway/server-methods/nodes-pending.ts @@ -0,0 +1,159 @@ +import { + drainNodePendingWork, + enqueueNodePendingWork, + type NodePendingWorkPriority, + type NodePendingWorkType, +} from "../node-pending-work.js"; +import { + ErrorCodes, + errorShape, + validateNodePendingDrainParams, + validateNodePendingEnqueueParams, +} from "../protocol/index.js"; +import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js"; +import { + maybeSendNodeWakeNudge, + maybeWakeNodeWithApns, + NODE_WAKE_RECONNECT_RETRY_WAIT_MS, + NODE_WAKE_RECONNECT_WAIT_MS, + waitForNodeReconnect, +} from "./nodes.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function resolveClientNodeId( + client: { connect?: { device?: { id?: string }; client?: { id?: string } } } | null, +): string | null { + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? ""; + const trimmed = nodeId.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export const nodePendingHandlers: GatewayRequestHandlers = { + "node.pending.drain": async ({ params, respond, client }) => { + if (!validateNodePendingDrainParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.drain", + validator: validateNodePendingDrainParams, + }); + return; + } + const nodeId = resolveClientNodeId(client); + if (!nodeId) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "node.pending.drain requires a connected device identity", + ), + ); + return; + } + const p = params as { maxItems?: number }; + const drained = drainNodePendingWork(nodeId, { + maxItems: p.maxItems, + includeDefaultStatus: true, + }); + respond(true, { nodeId, ...drained }, undefined); + }, + "node.pending.enqueue": async ({ params, respond, context }) => { + if (!validateNodePendingEnqueueParams(params)) { + respondInvalidParams({ + respond, + method: "node.pending.enqueue", + validator: validateNodePendingEnqueueParams, + }); + return; + } + const p = params as { + nodeId: string; + type: NodePendingWorkType; + priority?: NodePendingWorkPriority; + expiresInMs?: number; + wake?: boolean; + }; + await respondUnavailableOnThrow(respond, async () => { + const queued = enqueueNodePendingWork({ + nodeId: p.nodeId, + type: p.type, + priority: p.priority, + expiresInMs: p.expiresInMs, + }); + let wakeTriggered = false; + if (p.wake !== false && !queued.deduped && !context.nodeRegistry.get(p.nodeId)) { + const wakeReqId = queued.item.id; + context.logGateway.info( + `node pending wake start node=${p.nodeId} req=${wakeReqId} type=${queued.item.type}`, + ); + const wake = await maybeWakeNodeWithApns(p.nodeId, { wakeReason: "node.pending" }); + context.logGateway.info( + `node pending wake stage=wake1 node=${p.nodeId} req=${wakeReqId} ` + + `available=${wake.available} throttled=${wake.throttled} ` + + `path=${wake.path} durationMs=${wake.durationMs} ` + + `apnsStatus=${wake.apnsStatus ?? -1} apnsReason=${wake.apnsReason ?? "-"}`, + ); + wakeTriggered = wake.available; + if (wake.available) { + const reconnected = await waitForNodeReconnect({ + nodeId: p.nodeId, + context, + timeoutMs: NODE_WAKE_RECONNECT_WAIT_MS, + }); + context.logGateway.info( + `node pending wake stage=wait1 node=${p.nodeId} req=${wakeReqId} ` + + `reconnected=${reconnected} timeoutMs=${NODE_WAKE_RECONNECT_WAIT_MS}`, + ); + } + if (!context.nodeRegistry.get(p.nodeId) && wake.available) { + const retryWake = await maybeWakeNodeWithApns(p.nodeId, { + force: true, + wakeReason: "node.pending", + }); + context.logGateway.info( + `node pending wake stage=wake2 node=${p.nodeId} req=${wakeReqId} force=true ` + + `available=${retryWake.available} throttled=${retryWake.throttled} ` + + `path=${retryWake.path} durationMs=${retryWake.durationMs} ` + + `apnsStatus=${retryWake.apnsStatus ?? -1} apnsReason=${retryWake.apnsReason ?? "-"}`, + ); + if (retryWake.available) { + const reconnected = await waitForNodeReconnect({ + nodeId: p.nodeId, + context, + timeoutMs: NODE_WAKE_RECONNECT_RETRY_WAIT_MS, + }); + context.logGateway.info( + `node pending wake stage=wait2 node=${p.nodeId} req=${wakeReqId} ` + + `reconnected=${reconnected} timeoutMs=${NODE_WAKE_RECONNECT_RETRY_WAIT_MS}`, + ); + } + } + if (!context.nodeRegistry.get(p.nodeId)) { + const nudge = await maybeSendNodeWakeNudge(p.nodeId); + context.logGateway.info( + `node pending wake nudge node=${p.nodeId} req=${wakeReqId} sent=${nudge.sent} ` + + `throttled=${nudge.throttled} reason=${nudge.reason} durationMs=${nudge.durationMs} ` + + `apnsStatus=${nudge.apnsStatus ?? -1} apnsReason=${nudge.apnsReason ?? "-"}`, + ); + context.logGateway.warn( + `node pending wake done node=${p.nodeId} req=${wakeReqId} connected=false reason=not_connected`, + ); + } else { + context.logGateway.info( + `node pending wake done node=${p.nodeId} req=${wakeReqId} connected=true`, + ); + } + } + respond( + true, + { + nodeId: p.nodeId, + revision: queued.revision, + queued: queued.item, + wakeTriggered, + }, + undefined, + ); + }); + }, +}; diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 22e3c0912e4b5..fadbb0e374246 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -47,9 +47,9 @@ import { } from "./nodes.helpers.js"; import type { GatewayRequestHandlers } from "./types.js"; -const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; -const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; -const NODE_WAKE_RECONNECT_POLL_MS = 150; +export const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; +export const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000; +export const NODE_WAKE_RECONNECT_POLL_MS = 150; const NODE_WAKE_THROTTLE_MS = 15_000; const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000; const NODE_PENDING_ACTION_TTL_MS = 10 * 60_000; @@ -208,9 +208,9 @@ function toPendingParamsJSON(params: unknown): string | undefined { } } -async function maybeWakeNodeWithApns( +export async function maybeWakeNodeWithApns( nodeId: string, - opts?: { force?: boolean }, + opts?: { force?: boolean; wakeReason?: string }, ): Promise { const state = nodeWakeById.get(nodeId) ?? { lastWakeAtMs: 0 }; nodeWakeById.set(nodeId, state); @@ -253,7 +253,7 @@ async function maybeWakeNodeWithApns( auth: auth.value, registration, nodeId, - wakeReason: "node.invoke", + wakeReason: opts?.wakeReason ?? "node.invoke", }); if (!wakeResult.ok) { return withDuration({ @@ -298,7 +298,7 @@ async function maybeWakeNodeWithApns( } } -async function maybeSendNodeWakeNudge(nodeId: string): Promise { +export async function maybeSendNodeWakeNudge(nodeId: string): Promise { const startedAtMs = Date.now(); const withDuration = ( attempt: Omit, @@ -362,7 +362,7 @@ async function maybeSendNodeWakeNudge(nodeId: string): Promise unknown } }; timeoutMs?: number; From 1bc59cc09df21d65e817791eaec58ebd707d6e50 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:56:00 +0100 Subject: [PATCH 282/311] Gateway: tighten node pending drain semantics (#41429) Merged via squash. Prepared head SHA: 361c2eb5c84e3b532862d843536ca68b21336fb2 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/gateway/node-pending-work.test.ts | 21 +++++++++++++++++++ src/gateway/node-pending-work.ts | 29 ++++++++++++++++++--------- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b25de7522da4..98fcb8153a91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. +- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. ## 2026.3.8 diff --git a/src/gateway/node-pending-work.test.ts b/src/gateway/node-pending-work.test.ts index 3c2222dd3a931..2e89e2f20b21f 100644 --- a/src/gateway/node-pending-work.test.ts +++ b/src/gateway/node-pending-work.test.ts @@ -3,6 +3,7 @@ import { acknowledgeNodePendingWork, drainNodePendingWork, enqueueNodePendingWork, + getNodePendingWorkStateCountForTests, resetNodePendingWorkForTests, } from "./node-pending-work.js"; @@ -43,4 +44,24 @@ describe("node pending work", () => { const afterAck = drainNodePendingWork("node-2"); expect(afterAck.items.map((item) => item.id)).toEqual(["baseline-status"]); }); + + it("keeps hasMore true when the baseline status item is deferred by maxItems", () => { + enqueueNodePendingWork({ nodeId: "node-3", type: "location.request" }); + + const drained = drainNodePendingWork("node-3", { maxItems: 1 }); + + expect(drained.items.map((item) => item.type)).toEqual(["location.request"]); + expect(drained.hasMore).toBe(true); + }); + + it("does not allocate state for drain-only nodes with no queued work", () => { + expect(getNodePendingWorkStateCountForTests()).toBe(0); + + const drained = drainNodePendingWork("node-4"); + const acked = acknowledgeNodePendingWork({ nodeId: "node-4", itemIds: ["baseline-status"] }); + + expect(drained.items.map((item) => item.id)).toEqual(["baseline-status"]); + expect(acked).toEqual({ revision: 0, removedItemIds: [] }); + expect(getNodePendingWorkStateCountForTests()).toBe(0); + }); }); diff --git a/src/gateway/node-pending-work.ts b/src/gateway/node-pending-work.ts index 33d356777d259..437b8c12bb7ca 100644 --- a/src/gateway/node-pending-work.ts +++ b/src/gateway/node-pending-work.ts @@ -45,7 +45,7 @@ const PRIORITY_RANK: Record = { const stateByNodeId = new Map(); -function getState(nodeId: string): NodePendingWorkState { +function getOrCreateState(nodeId: string): NodePendingWorkState { let state = stateByNodeId.get(nodeId); if (!state) { state = { @@ -106,7 +106,7 @@ export function enqueueNodePendingWork(params: { throw new Error("nodeId required"); } const nowMs = Date.now(); - const state = getState(nodeId); + const state = getOrCreateState(nodeId); pruneExpired(state, nowMs); const existing = [...state.itemsById.values()].find((item) => item.type === params.type); if (existing) { @@ -134,21 +134,25 @@ export function drainNodePendingWork(nodeId: string, opts: DrainOptions = {}): D return { revision: 0, items: [], hasMore: false }; } const nowMs = opts.nowMs ?? Date.now(); - const state = getState(normalizedNodeId); - pruneExpired(state, nowMs); + const state = stateByNodeId.get(normalizedNodeId); + const revision = state?.revision ?? 0; + if (state) { + pruneExpired(state, nowMs); + } const maxItems = Math.min(MAX_ITEMS, Math.max(1, Math.trunc(opts.maxItems ?? DEFAULT_MAX_ITEMS))); - const explicitItems = sortedItems(state); + const explicitItems = state ? sortedItems(state) : []; const items = explicitItems.slice(0, maxItems); const hasExplicitStatus = explicitItems.some((item) => item.type === "status.request"); const includeBaseline = opts.includeDefaultStatus !== false && !hasExplicitStatus; if (includeBaseline && items.length < maxItems) { items.push(makeBaselineStatusItem(nowMs)); } + const explicitReturnedCount = items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length; + const baselineIncluded = items.some((item) => item.id === DEFAULT_STATUS_ITEM_ID); return { - revision: state.revision, + revision, items, - hasMore: - explicitItems.length > items.filter((item) => item.id !== DEFAULT_STATUS_ITEM_ID).length, + hasMore: explicitItems.length > explicitReturnedCount || (includeBaseline && !baselineIncluded), }; } @@ -160,7 +164,10 @@ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: st if (!nodeId) { return { revision: 0, removedItemIds: [] }; } - const state = getState(nodeId); + const state = stateByNodeId.get(nodeId); + if (!state) { + return { revision: 0, removedItemIds: [] }; + } const removedItemIds: string[] = []; for (const itemId of params.itemIds) { const trimmedId = itemId.trim(); @@ -180,3 +187,7 @@ export function acknowledgeNodePendingWork(params: { nodeId: string; itemIds: st export function resetNodePendingWorkForTests() { stateByNodeId.clear(); } + +export function getNodePendingWorkStateCountForTests(): number { + return stateByNodeId.size; +} From e6e4169e82536d9298002cd58a5f34d0a34c3be8 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:01:30 +0100 Subject: [PATCH 283/311] acp: fail honestly in bridge mode (#41424) Merged via squash. Prepared head SHA: b5e6e13afe917f47e0bb303159430930591c0c87 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 34 +++++++++ docs/cli/acp.md | 32 ++++++++ src/acp/translator.session-rate-limit.test.ts | 74 +++++++++++++++++++ src/acp/translator.ts | 19 +++-- 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fcb8153a91e..bad484b74df24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927. - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. +- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index cfe7349c3413a..486131381415c 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -17,6 +17,40 @@ Key goals: - Works with existing Gateway session store (list/resolve/reset). - Safe defaults (isolated ACP session keys by default). +## Bridge Scope + +`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor +runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway +session with predictable session mapping and basic streaming updates. + +## Compatibility Matrix + +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | + +## Known Limitations + +- `loadSession` rebinds to an existing Gateway session, but it does not replay + prior user or assistant history yet. +- If multiple ACP clients share the same Gateway session key, event and cancel + routing are best-effort rather than strictly isolated per client. Prefer the + default isolated `acp:` sessions when you need clean editor-local + turns. +- Gateway stop states are translated into ACP stop reasons, but that mapping is + less expressive than a fully ACP-native runtime. +- Tool follow-along data is intentionally narrow in bridge mode. The bridge + does not yet emit ACP terminals, file locations, or structured diffs. + ## How can I use this Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 7650390ed5554..fbd46e428d3bc 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -13,6 +13,38 @@ Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge t This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway over WebSocket. It keeps ACP sessions mapped to Gateway session keys. +`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor +runtime. It focuses on session routing, prompt delivery, and basic streaming +updates. + +## Compatibility Matrix + +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | + +## Known Limitations + +- `loadSession` rebinds to an existing Gateway session, but it does not replay + prior user or assistant history yet. +- If multiple ACP clients share the same Gateway session key, event and cancel + routing are best-effort rather than strictly isolated per client. Prefer the + default isolated `acp:` sessions when you need clean editor-local + turns. +- Gateway stop states are translated into ACP stop reasons, but that mapping is + less expressive than a fully ACP-native runtime. +- Tool follow-along data is intentionally narrow in bridge mode. The bridge + does not yet emit ACP terminals, file locations, or structured diffs. + ## Usage ```bash diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 2e7d03b0f7bc8..51d6cc1f8e2d1 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -2,6 +2,7 @@ import type { LoadSessionRequest, NewSessionRequest, PromptRequest, + SetSessionModeRequest, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; @@ -38,6 +39,14 @@ function createPromptRequest( } as unknown as PromptRequest; } +function createSetSessionModeRequest(sessionId: string, modeId: string): SetSessionModeRequest { + return { + sessionId, + modeId, + _meta: {}, + } as unknown as SetSessionModeRequest; +} + async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); @@ -97,6 +106,71 @@ describe("acp session creation rate limit", () => { }); }); +describe("acp unsupported bridge session setup", () => { + it("rejects per-session MCP servers on newSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const agent = new AcpGatewayAgent(connection, createAcpGateway(), { + sessionStore, + }); + + await expect( + agent.newSession({ + ...createNewSessionRequest(), + mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], + }), + ).rejects.toThrow(/does not support per-session MCP servers/i); + + expect(sessionStore.hasSession("docs-session")).toBe(false); + expect(sessionUpdate).not.toHaveBeenCalled(); + sessionStore.clearAllSessionsForTest(); + }); + + it("rejects per-session MCP servers on loadSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const agent = new AcpGatewayAgent(connection, createAcpGateway(), { + sessionStore, + }); + + await expect( + agent.loadSession({ + ...createLoadSessionRequest("docs-session"), + mcpServers: [{ name: "docs", command: "mcp-docs" }] as never[], + }), + ).rejects.toThrow(/does not support per-session MCP servers/i); + + expect(sessionStore.hasSession("docs-session")).toBe(false); + expect(sessionUpdate).not.toHaveBeenCalled(); + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp setSessionMode bridge behavior", () => { + it("surfaces gateway mode patch failures instead of succeeding silently", async () => { + const sessionStore = createInMemorySessionStore(); + const request = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + throw new Error("gateway rejected mode"); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("mode-session")); + + await expect( + agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")), + ).rejects.toThrow(/gateway rejected mode/i); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp prompt size hardening", () => { it("rejects oversized prompt blocks without leaking active runs", async () => { await expectOversizedPromptRejected({ diff --git a/src/acp/translator.ts b/src/acp/translator.ts index d399228afa666..6be5f72510ffa 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -170,9 +170,7 @@ export class AcpGatewayAgent implements Agent { } async newSession(params: NewSessionRequest): Promise { - if (params.mcpServers.length > 0) { - this.log(`ignoring ${params.mcpServers.length} MCP servers`); - } + this.assertSupportedSessionSetup(params.mcpServers); this.enforceSessionCreateRateLimit("newSession"); const sessionId = randomUUID(); @@ -193,9 +191,7 @@ export class AcpGatewayAgent implements Agent { } async loadSession(params: LoadSessionRequest): Promise { - if (params.mcpServers.length > 0) { - this.log(`ignoring ${params.mcpServers.length} MCP servers`); - } + this.assertSupportedSessionSetup(params.mcpServers); if (!this.sessionStore.hasSession(params.sessionId)) { this.enforceSessionCreateRateLimit("loadSession"); } @@ -256,7 +252,7 @@ export class AcpGatewayAgent implements Agent { this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); - throw err; + throw err instanceof Error ? err : new Error(String(err)); } return {}; } @@ -536,6 +532,15 @@ export class AcpGatewayAgent implements Agent { }); } + private assertSupportedSessionSetup(mcpServers: ReadonlyArray): void { + if (mcpServers.length === 0) { + return; + } + throw new Error( + "ACP bridge mode does not support per-session MCP servers. Configure MCP on the OpenClaw gateway or agent instead.", + ); + } + private enforceSessionCreateRateLimit(method: "newSession" | "loadSession"): void { const budget = this.sessionCreateRateLimiter.consume(); if (budget.allowed) { From d346f2d9ce6d2aefa18b0f8fc4fa90507a456b65 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:17:19 +0100 Subject: [PATCH 284/311] acp: restore session context and controls (#41425) Merged via squash. Prepared head SHA: fcabdf7c31e33bbbd3ef82bdee92755eb0f62c82 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 46 +- docs/cli/acp.md | 46 +- src/acp/translator.session-rate-limit.test.ts | 467 ++++++++++++++++- src/acp/translator.test-helpers.ts | 12 +- src/acp/translator.ts | 478 +++++++++++++++++- 6 files changed, 1003 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad484b74df24..5e80d751c9cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. +- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index 486131381415c..99fe15fbbd62e 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -25,31 +25,41 @@ session with predictable session mapping and basic streaming updates. ## Compatibility Matrix -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | +| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | ## Known Limitations -- `loadSession` rebinds to an existing Gateway session, but it does not replay - prior user or assistant history yet. +- `loadSession` replays stored user and assistant text history, but it does not + reconstruct historic tool calls, system notices, or richer ACP-native event + types. - If multiple ACP clients share the same Gateway session key, event and cancel routing are best-effort rather than strictly isolated per client. Prefer the default isolated `acp:` sessions when you need clean editor-local turns. - Gateway stop states are translated into ACP stop reasons, but that mapping is less expressive than a fully ACP-native runtime. -- Tool follow-along data is intentionally narrow in bridge mode. The bridge - does not yet emit ACP terminals, file locations, or structured diffs. +- Initial session controls currently surface a focused subset of Gateway knobs: + thought level, tool verbosity, reasoning, usage detail, and elevated + actions. Model selection and exec-host controls are not yet exposed as ACP + config options. +- `session_info_update` and `usage_update` are derived from Gateway session + snapshots, not live ACP-native runtime accounting. Usage is approximate, + carries no cost data, and is only emitted when the Gateway marks total token + data as fresh. +- Tool follow-along data is still intentionally narrow in bridge mode. The + bridge does not yet emit ACP terminals, file locations, or structured diffs. ## How can I use this @@ -215,9 +225,11 @@ updates. Terminal Gateway states map to ACP `done` with stop reasons: ## Compatibility -- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x). +- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x). - Works with ACP clients that implement `initialize`, `newSession`, `loadSession`, `prompt`, `cancel`, and `listSessions`. +- Bridge mode rejects per-session `mcpServers` instead of silently ignoring + them. Configure MCP at the Gateway or agent layer. ## Testing diff --git a/docs/cli/acp.md b/docs/cli/acp.md index fbd46e428d3bc..7693d7078626d 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -19,31 +19,41 @@ updates. ## Compatibility Matrix -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key. Stored history is not replayed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported, but this bridge does not yet expose broader ACP-native mode or config surfaces. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without ACP-native terminal or richer editor metadata. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | +| ACP area | Status | Notes | +| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | +| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | +| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | +| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | +| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | +| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | +| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | +| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | +| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | +| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | ## Known Limitations -- `loadSession` rebinds to an existing Gateway session, but it does not replay - prior user or assistant history yet. +- `loadSession` replays stored user and assistant text history, but it does not + reconstruct historic tool calls, system notices, or richer ACP-native event + types. - If multiple ACP clients share the same Gateway session key, event and cancel routing are best-effort rather than strictly isolated per client. Prefer the default isolated `acp:` sessions when you need clean editor-local turns. - Gateway stop states are translated into ACP stop reasons, but that mapping is less expressive than a fully ACP-native runtime. -- Tool follow-along data is intentionally narrow in bridge mode. The bridge - does not yet emit ACP terminals, file locations, or structured diffs. +- Initial session controls currently surface a focused subset of Gateway knobs: + thought level, tool verbosity, reasoning, usage detail, and elevated + actions. Model selection and exec-host controls are not yet exposed as ACP + config options. +- `session_info_update` and `usage_update` are derived from Gateway session + snapshots, not live ACP-native runtime accounting. Usage is approximate, + carries no cost data, and is only emitted when the Gateway marks total token + data as fresh. +- Tool follow-along data is still intentionally narrow in bridge mode. The + bridge does not yet emit ACP terminals, file locations, or structured diffs. ## Usage @@ -128,6 +138,10 @@ Each ACP session maps to a single Gateway session key. One agent can have many sessions; ACP defaults to an isolated `acp:` session unless you override the key or label. +Per-session `mcpServers` are not supported in bridge mode. If an ACP client +sends them during `newSession` or `loadSession`, the bridge returns a clear +error instead of silently ignoring them. + ## Use from `acpx` (Codex, Claude, other ACP clients) If you want a coding agent such as Codex or Claude Code to talk to your diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 51d6cc1f8e2d1..07d8bbc3db7d0 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -2,10 +2,12 @@ import type { LoadSessionRequest, NewSessionRequest, PromptRequest, + SetSessionConfigOptionRequest, SetSessionModeRequest, } from "@agentclientprotocol/sdk"; import { describe, expect, it, vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; @@ -47,6 +49,29 @@ function createSetSessionModeRequest(sessionId: string, modeId: string): SetSess } as unknown as SetSessionModeRequest; } +function createSetSessionConfigOptionRequest( + sessionId: string, + configId: string, + value: string, +): SetSessionConfigOptionRequest { + return { + sessionId, + configId, + value, + _meta: {}, + } as unknown as SetSessionConfigOptionRequest; +} + +function createChatFinalEvent(sessionKey: string): EventFrame { + return { + event: "chat", + payload: { + sessionKey, + state: "final", + }, + } as unknown as EventFrame; +} + async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) { const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"]; const sessionStore = createInMemorySessionStore(); @@ -110,7 +135,7 @@ describe("acp unsupported bridge session setup", () => { it("rejects per-session MCP servers on newSession", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); - const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const sessionUpdate = connection.__sessionUpdateMock; const agent = new AcpGatewayAgent(connection, createAcpGateway(), { sessionStore, }); @@ -130,7 +155,7 @@ describe("acp unsupported bridge session setup", () => { it("rejects per-session MCP servers on loadSession", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); - const sessionUpdate = vi.spyOn(connection, "sessionUpdate"); + const sessionUpdate = connection.__sessionUpdateMock; const agent = new AcpGatewayAgent(connection, createAcpGateway(), { sessionStore, }); @@ -148,6 +173,172 @@ describe("acp unsupported bridge session setup", () => { }); }); +describe("acp session UX bridge behavior", () => { + it("returns initial modes and thought-level config options for new sessions", async () => { + const sessionStore = createInMemorySessionStore(); + const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), { + sessionStore, + }); + + const result = await agent.newSession(createNewSessionRequest()); + + expect(result.modes?.currentModeId).toBe("adaptive"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("adaptive"); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "adaptive", + category: "thought_level", + }), + expect.objectContaining({ + id: "verbose_level", + currentValue: "off", + }), + expect.objectContaining({ + id: "reasoning_level", + currentValue: "off", + }), + expect.objectContaining({ + id: "response_usage", + currentValue: "off", + }), + expect.objectContaining({ + id: "elevated_level", + currentValue: "off", + }), + ]), + ); + + sessionStore.clearAllSessionsForTest(); + }); + + it("replays user and assistant text history on loadSession and returns initial controls", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:work", + label: "main-work", + displayName: "Main work", + derivedTitle: "Fix ACP bridge", + kind: "direct", + updatedAt: 1_710_000_000_000, + thinkingLevel: "high", + modelProvider: "openai", + model: "gpt-5.4", + verboseLevel: "full", + reasoningLevel: "stream", + responseUsage: "tokens", + elevatedLevel: "ask", + totalTokens: 4096, + totalTokensFresh: true, + contextTokens: 8192, + }, + ], + }; + } + if (method === "sessions.get") { + return { + messages: [ + { role: "user", content: [{ type: "text", text: "Question" }] }, + { role: "assistant", content: [{ type: "text", text: "Answer" }] }, + { role: "system", content: [{ type: "text", text: "ignore me" }] }, + { role: "assistant", content: [{ type: "image", image: "skip" }] }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + const result = await agent.loadSession(createLoadSessionRequest("agent:main:work")); + + expect(result.modes?.currentModeId).toBe("high"); + expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh"); + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "high", + }), + expect.objectContaining({ + id: "verbose_level", + currentValue: "full", + }), + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + expect.objectContaining({ + id: "response_usage", + currentValue: "tokens", + }), + expect.objectContaining({ + id: "elevated_level", + currentValue: "ask", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Question" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Answer" }, + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: expect.objectContaining({ + sessionUpdate: "available_commands_update", + }), + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "session_info_update", + title: "Fix ACP bridge", + updatedAt: "2024-03-09T16:00:00.000Z", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:work", + update: { + sessionUpdate: "usage_update", + used: 4096, + size: 8192, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp setSessionMode bridge behavior", () => { it("surfaces gateway mode patch failures instead of succeeding silently", async () => { const sessionStore = createInMemorySessionStore(); @@ -169,6 +360,278 @@ describe("acp setSessionMode bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + + it("emits current mode and thought-level config updates after a successful mode change", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "mode-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "high", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("mode-session")); + sessionUpdate.mockClear(); + + await agent.setSessionMode(createSetSessionModeRequest("mode-session", "high")); + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "mode-session", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "high", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "mode-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "high", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp setSessionConfigOption bridge behavior", () => { + it("updates the thought-level config option and returns refreshed options", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "config-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("config-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("config-session", "thought_level", "minimal"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "minimal", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "config-session", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "minimal", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "config-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "thought_level", + currentValue: "minimal", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); + + it("updates non-mode ACP config options through gateway session patches", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "reasoning-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + reasoningLevel: "stream", + }, + ], + }; + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("reasoning-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("reasoning-session", "reasoning_level", "stream"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "reasoning-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "reasoning_level", + currentValue: "stream", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + +describe("acp session metadata and usage updates", () => { + it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "usage-session", + displayName: "Usage session", + kind: "direct", + updatedAt: 1_710_000_123_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + totalTokens: 1200, + totalTokensFresh: true, + contextTokens: 4000, + }, + ], + }; + } + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-session")); + sessionUpdate.mockClear(); + + const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello")); + await agent.handleGatewayEvent(createChatFinalEvent("usage-session")); + await promptPromise; + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "usage-session", + update: { + sessionUpdate: "session_info_update", + title: "Usage session", + updatedAt: "2024-03-09T16:02:03.000Z", + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "usage-session", + update: { + sessionUpdate: "usage_update", + used: 1200, + size: 4000, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp prompt size hardening", () => { diff --git a/src/acp/translator.test-helpers.ts b/src/acp/translator.test-helpers.ts index c80918ba2ccde..2bd7fd2747fe6 100644 --- a/src/acp/translator.test-helpers.ts +++ b/src/acp/translator.test-helpers.ts @@ -2,10 +2,16 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk"; import { vi } from "vitest"; import type { GatewayClient } from "../gateway/client.js"; -export function createAcpConnection(): AgentSideConnection { +export type TestAcpConnection = AgentSideConnection & { + __sessionUpdateMock: ReturnType; +}; + +export function createAcpConnection(): TestAcpConnection { + const sessionUpdate = vi.fn(async () => {}); return { - sessionUpdate: vi.fn(async () => {}), - } as unknown as AgentSideConnection; + sessionUpdate, + __sessionUpdateMock: sessionUpdate, + } as unknown as TestAcpConnection; } export function createAcpGateway( diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 6be5f72510ffa..8628117b49c52 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -16,14 +16,19 @@ import type { NewSessionResponse, PromptRequest, PromptResponse, + SessionConfigOption, + SessionModeState, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; +import { listThinkingLevels } from "../auto-reply/thinking.js"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; -import type { SessionsListResult } from "../gateway/session-utils.js"; +import type { GatewaySessionRow, SessionsListResult } from "../gateway/session-utils.js"; import { createFixedWindowRateLimiter, type FixedWindowRateLimiter, @@ -34,7 +39,6 @@ import { extractAttachmentsFromPrompt, extractTextFromPrompt, formatToolTitle, - inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; @@ -43,6 +47,12 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; // Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw) const MAX_PROMPT_BYTES = 2 * 1024 * 1024; +const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; +const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; +const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level"; +const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage"; +const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level"; +const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000; type PendingPrompt = { sessionId: string; @@ -59,9 +69,226 @@ type AcpGatewayAgentOptions = AcpServerOptions & { sessionStore?: AcpSessionStore; }; +type GatewaySessionPresentationRow = Pick< + GatewaySessionRow, + | "displayName" + | "label" + | "derivedTitle" + | "updatedAt" + | "thinkingLevel" + | "modelProvider" + | "model" + | "verboseLevel" + | "reasoningLevel" + | "responseUsage" + | "elevatedLevel" + | "totalTokens" + | "totalTokensFresh" + | "contextTokens" +>; + +type SessionPresentation = { + configOptions: SessionConfigOption[]; + modes: SessionModeState; +}; + +type SessionMetadata = { + title?: string | null; + updatedAt?: string | null; +}; + +type SessionUsageSnapshot = { + size: number; + used: number; +}; + +type SessionSnapshot = SessionPresentation & { + metadata?: SessionMetadata; + usage?: SessionUsageSnapshot; +}; + +type GatewayTranscriptMessage = { + role?: unknown; + content?: unknown; +}; + const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120; const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000; +function formatThinkingLevelName(level: string): string { + switch (level) { + case "xhigh": + return "Extra High"; + case "adaptive": + return "Adaptive"; + default: + return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown"; + } +} + +function buildThinkingModeDescription(level: string): string | undefined { + if (level === "adaptive") { + return "Use the Gateway session default thought level."; + } + return undefined; +} + +function formatConfigValueName(value: string): string { + switch (value) { + case "xhigh": + return "Extra High"; + default: + return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown"; + } +} + +function buildSelectConfigOption(params: { + id: string; + name: string; + description: string; + currentValue: string; + values: readonly string[]; + category?: string; +}): SessionConfigOption { + return { + type: "select", + id: params.id, + name: params.name, + category: params.category, + description: params.description, + currentValue: params.currentValue, + options: params.values.map((value) => ({ + value, + name: formatConfigValueName(value), + })), + }; +} + +function buildSessionPresentation(params: { + row?: GatewaySessionPresentationRow; + overrides?: Partial; +}): SessionPresentation { + const row = { + ...params.row, + ...params.overrides, + }; + const availableLevelIds: string[] = [...listThinkingLevels(row.modelProvider, row.model)]; + const currentModeId = row.thinkingLevel?.trim() || "adaptive"; + if (!availableLevelIds.includes(currentModeId)) { + availableLevelIds.push(currentModeId); + } + + const modes: SessionModeState = { + currentModeId, + availableModes: availableLevelIds.map((level) => ({ + id: level, + name: formatThinkingLevelName(level), + description: buildThinkingModeDescription(level), + })), + }; + + const configOptions: SessionConfigOption[] = [ + buildSelectConfigOption({ + id: ACP_THOUGHT_LEVEL_CONFIG_ID, + name: "Thought level", + category: "thought_level", + description: + "Controls how much deliberate reasoning OpenClaw requests from the Gateway model.", + currentValue: currentModeId, + values: availableLevelIds, + }), + buildSelectConfigOption({ + id: ACP_VERBOSE_LEVEL_CONFIG_ID, + name: "Tool verbosity", + description: + "Controls how much tool progress and output detail OpenClaw keeps enabled for the session.", + currentValue: row.verboseLevel?.trim() || "off", + values: ["off", "on", "full"], + }), + buildSelectConfigOption({ + id: ACP_REASONING_LEVEL_CONFIG_ID, + name: "Reasoning stream", + description: "Controls whether reasoning-capable models emit reasoning text for the session.", + currentValue: row.reasoningLevel?.trim() || "off", + values: ["off", "on", "stream"], + }), + buildSelectConfigOption({ + id: ACP_RESPONSE_USAGE_CONFIG_ID, + name: "Usage detail", + description: + "Controls how much usage information OpenClaw attaches to responses for the session.", + currentValue: row.responseUsage?.trim() || "off", + values: ["off", "tokens", "full"], + }), + buildSelectConfigOption({ + id: ACP_ELEVATED_LEVEL_CONFIG_ID, + name: "Elevated actions", + description: "Controls how aggressively the session allows elevated execution behavior.", + currentValue: row.elevatedLevel?.trim() || "off", + values: ["off", "on", "ask", "full"], + }), + ]; + + return { configOptions, modes }; +} + +function extractReplayText(content: unknown): string | undefined { + if (typeof content === "string") { + return content.length > 0 ? content : undefined; + } + if (!Array.isArray(content)) { + return undefined; + } + const text = content + .map((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) { + return ""; + } + const typedBlock = block as { type?: unknown; text?: unknown }; + return typedBlock.type === "text" && typeof typedBlock.text === "string" + ? typedBlock.text + : ""; + }) + .join(""); + return text.length > 0 ? text : undefined; +} + +function buildSessionMetadata(params: { + row?: GatewaySessionPresentationRow; + sessionKey: string; +}): SessionMetadata { + const title = + params.row?.derivedTitle?.trim() || + params.row?.displayName?.trim() || + params.row?.label?.trim() || + params.sessionKey; + const updatedAt = + typeof params.row?.updatedAt === "number" && Number.isFinite(params.row.updatedAt) + ? new Date(params.row.updatedAt).toISOString() + : null; + return { title, updatedAt }; +} + +function buildSessionUsageSnapshot( + row?: GatewaySessionPresentationRow, +): SessionUsageSnapshot | undefined { + const totalTokens = row?.totalTokens; + const contextTokens = row?.contextTokens; + if ( + row?.totalTokensFresh !== true || + typeof totalTokens !== "number" || + !Number.isFinite(totalTokens) || + typeof contextTokens !== "number" || + !Number.isFinite(contextTokens) || + contextTokens <= 0 + ) { + return undefined; + } + const size = Math.max(0, Math.floor(contextTokens)); + const used = Math.max(0, Math.min(Math.floor(totalTokens), size)); + return { size, used }; +} + function buildSystemInputProvenance(originSessionId: string) { return { kind: "external_user" as const, @@ -186,8 +413,17 @@ export class AcpGatewayAgent implements Agent { cwd: params.cwd, }); this.log(`newSession: ${session.sessionId} -> ${session.sessionKey}`); + const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: false, + }); await this.sendAvailableCommands(session.sessionId); - return { sessionId: session.sessionId }; + const { configOptions, modes } = sessionSnapshot; + return { + sessionId: session.sessionId, + configOptions, + modes, + }; } async loadSession(params: LoadSessionRequest): Promise { @@ -208,8 +444,17 @@ export class AcpGatewayAgent implements Agent { cwd: params.cwd, }); this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`); + const [sessionSnapshot, transcript] = await Promise.all([ + this.getSessionSnapshot(session.sessionKey), + this.getSessionTranscript(session.sessionKey), + ]); + await this.replaySessionTranscript(session.sessionId, transcript); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: false, + }); await this.sendAvailableCommands(session.sessionId); - return {}; + const { configOptions, modes } = sessionSnapshot; + return { configOptions, modes }; } async unstable_listSessions(params: ListSessionsRequest): Promise { @@ -250,6 +495,12 @@ export class AcpGatewayAgent implements Agent { thinkingLevel: params.modeId, }); this.log(`setSessionMode: ${session.sessionId} -> ${params.modeId}`); + const sessionSnapshot = await this.getSessionSnapshot(session.sessionKey, { + thinkingLevel: params.modeId, + }); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: true, + }); } catch (err) { this.log(`setSessionMode error: ${String(err)}`); throw err instanceof Error ? err : new Error(String(err)); @@ -257,6 +508,39 @@ export class AcpGatewayAgent implements Agent { return {}; } + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessionStore.getSession(params.sessionId); + if (!session) { + throw new Error(`Session ${params.sessionId} not found`); + } + const sessionPatch = this.resolveSessionConfigPatch(params.configId, params.value); + + try { + await this.gateway.request("sessions.patch", { + key: session.sessionKey, + ...sessionPatch.patch, + }); + this.log( + `setSessionConfigOption: ${session.sessionId} -> ${params.configId}=${params.value}`, + ); + const sessionSnapshot = await this.getSessionSnapshot( + session.sessionKey, + sessionPatch.overrides, + ); + await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { + includeControls: true, + }); + return { + configOptions: sessionSnapshot.configOptions, + }; + } catch (err) { + this.log(`setSessionConfigOption error: ${String(err)}`); + throw err instanceof Error ? err : new Error(String(err)); + } + } + async prompt(params: PromptRequest): Promise { const session = this.sessionStore.getSession(params.sessionId); if (!session) { @@ -412,7 +696,6 @@ export class AcpGatewayAgent implements Agent { title: formatToolTitle(name, args), status: "in_progress", rawInput: args, - kind: inferToolKind(name), }, }); return; @@ -420,6 +703,7 @@ export class AcpGatewayAgent implements Agent { if (phase === "result") { const isError = Boolean(data.isError); + pending.toolCalls?.delete(toolCallId); await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { @@ -462,11 +746,11 @@ export class AcpGatewayAgent implements Agent { if (state === "final") { const rawStopReason = payload.stopReason as string | undefined; const stopReason: StopReason = rawStopReason === "max_tokens" ? "max_tokens" : "end_turn"; - this.finishPrompt(pending.sessionId, pending, stopReason); + await this.finishPrompt(pending.sessionId, pending, stopReason); return; } if (state === "aborted") { - this.finishPrompt(pending.sessionId, pending, "cancelled"); + await this.finishPrompt(pending.sessionId, pending, "cancelled"); return; } if (state === "error") { @@ -474,7 +758,7 @@ export class AcpGatewayAgent implements Agent { // do not treat transient backend errors (timeouts, rate-limits) as deliberate // refusals. TODO: when ChatEventSchema gains a structured errorKind field // (e.g. "refusal" | "timeout" | "rate_limit"), use it to distinguish here. - this.finishPrompt(pending.sessionId, pending, "end_turn"); + void this.finishPrompt(pending.sessionId, pending, "end_turn"); } } @@ -507,9 +791,17 @@ export class AcpGatewayAgent implements Agent { }); } - private finishPrompt(sessionId: string, pending: PendingPrompt, stopReason: StopReason): void { + private async finishPrompt( + sessionId: string, + pending: PendingPrompt, + stopReason: StopReason, + ): Promise { this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); + const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey); + await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { + includeControls: false, + }); pending.resolve({ stopReason }); } @@ -532,6 +824,174 @@ export class AcpGatewayAgent implements Agent { }); } + private async getSessionSnapshot( + sessionKey: string, + overrides?: Partial, + ): Promise { + try { + const row = await this.getGatewaySessionRow(sessionKey); + return { + ...buildSessionPresentation({ row, overrides }), + metadata: buildSessionMetadata({ row, sessionKey }), + usage: buildSessionUsageSnapshot(row), + }; + } catch (err) { + this.log(`session presentation fallback for ${sessionKey}: ${String(err)}`); + return { + ...buildSessionPresentation({ overrides }), + metadata: buildSessionMetadata({ sessionKey }), + }; + } + } + + private async getGatewaySessionRow( + sessionKey: string, + ): Promise { + const result = await this.gateway.request("sessions.list", { + limit: 200, + search: sessionKey, + includeDerivedTitles: true, + }); + const session = result.sessions.find((entry) => entry.key === sessionKey); + if (!session) { + return undefined; + } + return { + displayName: session.displayName, + label: session.label, + derivedTitle: session.derivedTitle, + updatedAt: session.updatedAt, + thinkingLevel: session.thinkingLevel, + modelProvider: session.modelProvider, + model: session.model, + verboseLevel: session.verboseLevel, + reasoningLevel: session.reasoningLevel, + responseUsage: session.responseUsage, + elevatedLevel: session.elevatedLevel, + totalTokens: session.totalTokens, + totalTokensFresh: session.totalTokensFresh, + contextTokens: session.contextTokens, + }; + } + + private resolveSessionConfigPatch( + configId: string, + value: string, + ): { + overrides: Partial; + patch: Record; + } { + switch (configId) { + case ACP_THOUGHT_LEVEL_CONFIG_ID: + return { + patch: { thinkingLevel: value }, + overrides: { thinkingLevel: value }, + }; + case ACP_VERBOSE_LEVEL_CONFIG_ID: + return { + patch: { verboseLevel: value }, + overrides: { verboseLevel: value }, + }; + case ACP_REASONING_LEVEL_CONFIG_ID: + return { + patch: { reasoningLevel: value }, + overrides: { reasoningLevel: value }, + }; + case ACP_RESPONSE_USAGE_CONFIG_ID: + return { + patch: { responseUsage: value }, + overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] }, + }; + case ACP_ELEVATED_LEVEL_CONFIG_ID: + return { + patch: { elevatedLevel: value }, + overrides: { elevatedLevel: value }, + }; + default: + throw new Error(`ACP bridge mode does not support session config option "${configId}".`); + } + } + + private async getSessionTranscript(sessionKey: string): Promise { + const result = await this.gateway.request<{ messages?: unknown[] }>("sessions.get", { + key: sessionKey, + limit: ACP_LOAD_SESSION_REPLAY_LIMIT, + }); + if (!Array.isArray(result.messages)) { + return []; + } + return result.messages as GatewayTranscriptMessage[]; + } + + private async replaySessionTranscript( + sessionId: string, + transcript: ReadonlyArray, + ): Promise { + for (const message of transcript) { + const role = typeof message.role === "string" ? message.role : ""; + if (role !== "user" && role !== "assistant") { + continue; + } + const text = extractReplayText(message.content); + if (!text) { + continue; + } + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk", + content: { type: "text", text }, + }, + }); + } + } + + private async sendSessionSnapshotUpdate( + sessionId: string, + sessionSnapshot: SessionSnapshot, + options: { includeControls: boolean }, + ): Promise { + if (options.includeControls) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId: sessionSnapshot.modes.currentModeId, + }, + }); + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: sessionSnapshot.configOptions, + }, + }); + } + if (sessionSnapshot.metadata) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "session_info_update", + ...sessionSnapshot.metadata, + }, + }); + } + if (sessionSnapshot.usage) { + await this.connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "usage_update", + used: sessionSnapshot.usage.used, + size: sessionSnapshot.usage.size, + _meta: { + source: "gateway-session-store", + approximate: true, + }, + }, + }); + } + } + private assertSupportedSessionSetup(mcpServers: ReadonlyArray): void { if (mcpServers.length === 0) { return; From 30340d6835c02bacb31c89ee3dd66b4e02456635 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 00:18:41 +0300 Subject: [PATCH 285/311] Sandbox: import STATE_DIR from paths directly (#41439) --- src/agents/sandbox/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index f2a562f26b612..b2cc874b97f9c 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { CHANNEL_IDS } from "../../channels/registry.js"; -import { STATE_DIR } from "../../config/config.js"; +import { STATE_DIR } from "../../config/paths.js"; export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes"); From 8e3f3bc3cf4744e38442d177573f706b78fbc0c5 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:26:46 +0100 Subject: [PATCH 286/311] acp: enrich streaming updates for ide clients (#41442) Merged via squash. Prepared head SHA: 0764368e805403edda43c88418f322509bfc5c68 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs.acp.md | 7 +- docs/cli/acp.md | 7 +- src/acp/event-mapper.ts | 256 +++++++++++++++++- src/acp/translator.session-rate-limit.test.ts | 139 ++++++++++ src/acp/translator.ts | 50 +++- 6 files changed, 449 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e80d751c9cb7..fff858ba5fcc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. +- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs.acp.md b/docs.acp.md index 99fe15fbbd62e..1e93ee0cf63dc 100644 --- a/docs.acp.md +++ b/docs.acp.md @@ -33,7 +33,7 @@ session with predictable session mapping and basic streaming updates. | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | @@ -58,8 +58,9 @@ session with predictable session mapping and basic streaming updates. snapshots, not live ACP-native runtime accounting. Usage is approximate, carries no cost data, and is only emitted when the Gateway marks total token data as fresh. -- Tool follow-along data is still intentionally narrow in bridge mode. The - bridge does not yet emit ACP terminals, file locations, or structured diffs. +- Tool follow-along data is best-effort. The bridge can surface file paths that + appear in known tool args/results, but it does not yet emit ACP terminals or + structured file diffs. ## How can I use this diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 7693d7078626d..152770e6d864e 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -27,7 +27,7 @@ updates. | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | -| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | +| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | @@ -52,8 +52,9 @@ updates. snapshots, not live ACP-native runtime accounting. Usage is approximate, carries no cost data, and is only emitted when the Gateway marks total token data as fresh. -- Tool follow-along data is still intentionally narrow in bridge mode. The - bridge does not yet emit ACP terminals, file locations, or structured diffs. +- Tool follow-along data is best-effort. The bridge can surface file paths that + appear in known tool args/results, but it does not yet emit ACP terminals or + structured file diffs. ## Usage diff --git a/src/acp/event-mapper.ts b/src/acp/event-mapper.ts index 83b91524a7f88..2a74f5691cfcc 100644 --- a/src/acp/event-mapper.ts +++ b/src/acp/event-mapper.ts @@ -1,4 +1,10 @@ -import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk"; +import type { + ContentBlock, + ImageContent, + ToolCallContent, + ToolCallLocation, + ToolKind, +} from "@agentclientprotocol/sdk"; export type GatewayAttachment = { type: string; @@ -6,6 +12,39 @@ export type GatewayAttachment = { content: string; }; +const TOOL_LOCATION_PATH_KEYS = [ + "path", + "filePath", + "file_path", + "targetPath", + "target_path", + "targetFile", + "target_file", + "sourcePath", + "source_path", + "destinationPath", + "destination_path", + "oldPath", + "old_path", + "newPath", + "new_path", + "outputPath", + "output_path", + "inputPath", + "input_path", +] as const; + +const TOOL_LOCATION_LINE_KEYS = [ + "line", + "lineNumber", + "line_number", + "startLine", + "start_line", +] as const; +const TOOL_RESULT_PATH_MARKER_RE = /^(?:FILE|MEDIA):(.+)$/gm; +const TOOL_LOCATION_MAX_DEPTH = 4; +const TOOL_LOCATION_MAX_NODES = 100; + const INLINE_CONTROL_ESCAPE_MAP: Readonly> = { "\0": "\\0", "\r": "\\r", @@ -56,6 +95,150 @@ function escapeResourceTitle(value: string): string { return escapeInlineControlChars(value).replace(/[()[\]]/g, (char) => `\\${char}`); } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizeToolLocationPath(value: string): string | undefined { + const trimmed = value.trim(); + if ( + !trimmed || + trimmed.length > 4096 || + trimmed.includes("\u0000") || + trimmed.includes("\r") || + trimmed.includes("\n") + ) { + return undefined; + } + if (/^https?:\/\//i.test(trimmed)) { + return undefined; + } + if (/^file:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + return decodeURIComponent(parsed.pathname || "") || undefined; + } catch { + return undefined; + } + } + return trimmed; +} + +function normalizeToolLocationLine(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + const line = Math.floor(value); + return line > 0 ? line : undefined; +} + +function extractToolLocationLine(record: Record): number | undefined { + for (const key of TOOL_LOCATION_LINE_KEYS) { + const line = normalizeToolLocationLine(record[key]); + if (line !== undefined) { + return line; + } + } + return undefined; +} + +function addToolLocation( + locations: Map, + rawPath: string, + line?: number, +): void { + const path = normalizeToolLocationPath(rawPath); + if (!path) { + return; + } + for (const [existingKey, existing] of locations.entries()) { + if (existing.path !== path) { + continue; + } + if (line === undefined || existing.line === line) { + return; + } + if (existing.line === undefined) { + locations.delete(existingKey); + } + } + const locationKey = `${path}:${line ?? ""}`; + if (locations.has(locationKey)) { + return; + } + locations.set(locationKey, line ? { path, line } : { path }); +} + +function collectLocationsFromTextMarkers( + text: string, + locations: Map, +): void { + for (const match of text.matchAll(TOOL_RESULT_PATH_MARKER_RE)) { + const candidate = match[1]?.trim(); + if (candidate) { + addToolLocation(locations, candidate); + } + } +} + +function collectToolLocations( + value: unknown, + locations: Map, + state: { visited: number; depth: number }, +): void { + if (state.visited >= TOOL_LOCATION_MAX_NODES || state.depth > TOOL_LOCATION_MAX_DEPTH) { + return; + } + state.visited += 1; + + if (typeof value === "string") { + collectLocationsFromTextMarkers(value, locations); + return; + } + if (!value || typeof value !== "object") { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + collectToolLocations(item, locations, { visited: state.visited, depth: state.depth + 1 }); + state.visited += 1; + if (state.visited >= TOOL_LOCATION_MAX_NODES) { + return; + } + } + return; + } + + const record = value as Record; + const line = extractToolLocationLine(record); + for (const key of TOOL_LOCATION_PATH_KEYS) { + const rawPath = record[key]; + if (typeof rawPath === "string") { + addToolLocation(locations, rawPath, line); + } + } + + const content = Array.isArray(record.content) ? record.content : undefined; + if (content) { + for (const block of content) { + const entry = asRecord(block); + if (entry?.type === "text" && typeof entry.text === "string") { + collectLocationsFromTextMarkers(entry.text, locations); + } + } + } + + for (const nested of Object.values(record)) { + collectToolLocations(nested, locations, { visited: state.visited, depth: state.depth + 1 }); + state.visited += 1; + if (state.visited >= TOOL_LOCATION_MAX_NODES) { + return; + } + } +} + export function extractTextFromPrompt(prompt: ContentBlock[], maxBytes?: number): string { const parts: string[] = []; // Track accumulated byte count per block to catch oversized prompts before full concatenation @@ -152,3 +335,74 @@ export function inferToolKind(name?: string): ToolKind { } return "other"; } + +export function extractToolCallContent(value: unknown): ToolCallContent[] | undefined { + if (typeof value === "string") { + return value.trim() + ? [ + { + type: "content", + content: { + type: "text", + text: value, + }, + }, + ] + : undefined; + } + + const record = asRecord(value); + if (!record) { + return undefined; + } + + const contents: ToolCallContent[] = []; + const blocks = Array.isArray(record.content) ? record.content : []; + for (const block of blocks) { + const entry = asRecord(block); + if (entry?.type === "text" && typeof entry.text === "string" && entry.text.trim()) { + contents.push({ + type: "content", + content: { + type: "text", + text: entry.text, + }, + }); + } + } + + if (contents.length > 0) { + return contents; + } + + const fallbackText = + typeof record.text === "string" + ? record.text + : typeof record.message === "string" + ? record.message + : typeof record.error === "string" + ? record.error + : undefined; + + if (!fallbackText?.trim()) { + return undefined; + } + + return [ + { + type: "content", + content: { + type: "text", + text: fallbackText, + }, + }, + ]; +} + +export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined { + const locations = new Map(); + for (const value of values) { + collectToolLocations(value, locations, { visited: 0, depth: 0 }); + } + return locations.size > 0 ? [...locations.values()] : undefined; +} diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 07d8bbc3db7d0..a591d30e1ac56 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -62,6 +62,34 @@ function createSetSessionConfigOptionRequest( } as unknown as SetSessionConfigOptionRequest; } +function createToolEvent(params: { + sessionKey: string; + phase: "start" | "update" | "result"; + toolCallId: string; + name: string; + args?: Record; + partialResult?: unknown; + result?: unknown; + isError?: boolean; +}): EventFrame { + return { + event: "agent", + payload: { + sessionKey: params.sessionKey, + stream: "tool", + data: { + phase: params.phase, + toolCallId: params.toolCallId, + name: params.name, + args: params.args, + partialResult: params.partialResult, + result: params.result, + isError: params.isError, + }, + }, + } as unknown as EventFrame; +} + function createChatFinalEvent(sessionKey: string): EventFrame { return { event: "chat", @@ -561,6 +589,117 @@ describe("acp setSessionConfigOption bridge behavior", () => { }); }); +describe("acp tool streaming bridge behavior", () => { + it("maps Gateway tool partial output and file locations into ACP tool updates", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("tool-session")); + sessionUpdate.mockClear(); + + const promptPromise = agent.prompt(createPromptRequest("tool-session", "Inspect app.ts")); + + await agent.handleGatewayEvent( + createToolEvent({ + sessionKey: "tool-session", + phase: "start", + toolCallId: "tool-1", + name: "read", + args: { path: "src/app.ts", line: 12 }, + }), + ); + await agent.handleGatewayEvent( + createToolEvent({ + sessionKey: "tool-session", + phase: "update", + toolCallId: "tool-1", + name: "read", + partialResult: { + content: [{ type: "text", text: "partial output" }], + details: { path: "src/app.ts" }, + }, + }), + ); + await agent.handleGatewayEvent( + createToolEvent({ + sessionKey: "tool-session", + phase: "result", + toolCallId: "tool-1", + name: "read", + result: { + content: [{ type: "text", text: "FILE:src/app.ts" }], + details: { path: "src/app.ts" }, + }, + }), + ); + await agent.handleGatewayEvent(createChatFinalEvent("tool-session")); + await promptPromise; + + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "tool-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "read: path: src/app.ts, line: 12", + status: "in_progress", + rawInput: { path: "src/app.ts", line: 12 }, + kind: "read", + locations: [{ path: "src/app.ts", line: 12 }], + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "tool-session", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "in_progress", + rawOutput: { + content: [{ type: "text", text: "partial output" }], + details: { path: "src/app.ts" }, + }, + content: [ + { + type: "content", + content: { type: "text", text: "partial output" }, + }, + ], + locations: [{ path: "src/app.ts", line: 12 }], + }, + }); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "tool-session", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + content: [{ type: "text", text: "FILE:src/app.ts" }], + details: { path: "src/app.ts" }, + }, + content: [ + { + type: "content", + content: { type: "text", text: "FILE:src/app.ts" }, + }, + ], + locations: [{ path: "src/app.ts", line: 12 }], + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); +}); + describe("acp session metadata and usage updates", () => { it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => { const sessionStore = createInMemorySessionStore(); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 8628117b49c52..e7fa4a7382ebe 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -23,6 +23,8 @@ import type { SetSessionModeRequest, SetSessionModeResponse, StopReason, + ToolCallLocation, + ToolKind, } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import { listThinkingLevels } from "../auto-reply/thinking.js"; @@ -37,8 +39,11 @@ import { shortenHomePath } from "../utils.js"; import { getAvailableCommands } from "./commands.js"; import { extractAttachmentsFromPrompt, + extractToolCallContent, + extractToolCallLocations, extractTextFromPrompt, formatToolTitle, + inferToolKind, } from "./event-mapper.js"; import { readBool, readNumber, readString } from "./meta.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; @@ -62,7 +67,14 @@ type PendingPrompt = { reject: (err: Error) => void; sentTextLength?: number; sentText?: string; - toolCalls?: Set; + toolCalls?: Map; +}; + +type PendingToolCall = { + kind: ToolKind; + locations?: ToolCallLocation[]; + rawInput?: Record; + title: string; }; type AcpGatewayAgentOptions = AcpServerOptions & { @@ -681,21 +693,48 @@ export class AcpGatewayAgent implements Agent { if (phase === "start") { if (!pending.toolCalls) { - pending.toolCalls = new Set(); + pending.toolCalls = new Map(); } if (pending.toolCalls.has(toolCallId)) { return; } - pending.toolCalls.add(toolCallId); const args = data.args as Record | undefined; + const title = formatToolTitle(name, args); + const kind = inferToolKind(name); + const locations = extractToolCallLocations(args); + pending.toolCalls.set(toolCallId, { + title, + kind, + rawInput: args, + locations, + }); await this.connection.sessionUpdate({ sessionId: pending.sessionId, update: { sessionUpdate: "tool_call", toolCallId, - title: formatToolTitle(name, args), + title, status: "in_progress", rawInput: args, + kind, + locations, + }, + }); + return; + } + + if (phase === "update") { + const toolState = pending.toolCalls?.get(toolCallId); + const partialResult = data.partialResult; + await this.connection.sessionUpdate({ + sessionId: pending.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + rawOutput: partialResult, + content: extractToolCallContent(partialResult), + locations: extractToolCallLocations(toolState?.locations, partialResult), }, }); return; @@ -703,6 +742,7 @@ export class AcpGatewayAgent implements Agent { if (phase === "result") { const isError = Boolean(data.isError); + const toolState = pending.toolCalls?.get(toolCallId); pending.toolCalls?.delete(toolCallId); await this.connection.sessionUpdate({ sessionId: pending.sessionId, @@ -711,6 +751,8 @@ export class AcpGatewayAgent implements Agent { toolCallId, status: isError ? "failed" : "completed", rawOutput: data.result, + content: extractToolCallContent(data.result), + locations: extractToolCallLocations(toolState?.locations, data.result), }, }); } From 4aebff78bc32b9ed15e4889510c8285507bda6d7 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:32:32 +0100 Subject: [PATCH 287/311] acp: forward attachments into ACP runtime sessions (#41427) Merged via squash. Prepared head SHA: f2ac51df2c4c84a7c3f7150cb736b087d592ac94 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + extensions/acpx/src/runtime.ts | 15 ++++++++- src/acp/control-plane/manager.core.ts | 1 + src/acp/control-plane/manager.types.ts | 6 ++++ src/acp/runtime/types.ts | 6 ++++ src/auto-reply/reply/dispatch-acp.test.ts | 33 +++++++++++++++++++ src/auto-reply/reply/dispatch-acp.ts | 40 ++++++++++++++++++++++- 7 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff858ba5fcc7..7d0a735cb1879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. - ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. +- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. ## 2026.3.8 diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 5fa56d109e528..7e310638699c4 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -310,7 +310,20 @@ export class AcpxRuntime implements AcpRuntime { // Ignore EPIPE when the child exits before stdin flush completes. }); - child.stdin.end(input.text); + if (input.attachments && input.attachments.length > 0) { + const blocks: unknown[] = []; + if (input.text) { + blocks.push({ type: "text", text: input.text }); + } + for (const attachment of input.attachments) { + if (attachment.mediaType.startsWith("image/")) { + blocks.push({ type: "image", mimeType: attachment.mediaType, data: attachment.data }); + } + } + child.stdin.end(blocks.length > 0 ? JSON.stringify(blocks) : input.text); + } else { + child.stdin.end(input.text); + } let stderr = ""; child.stderr.on("data", (chunk) => { diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index a64b1fae7eba5..f511355ae87a0 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -655,6 +655,7 @@ export class AcpSessionManager { for await (const event of runtime.runTurn({ handle, text: input.text, + attachments: input.attachments, mode: input.mode, requestId: input.requestId, signal: combinedSignal, diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index 7337e8063f9cf..33c2355305c24 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -47,10 +47,16 @@ export type AcpInitializeSessionInput = { backendId?: string; }; +export type AcpTurnAttachment = { + mediaType: string; + data: string; +}; + export type AcpRunTurnInput = { cfg: OpenClawConfig; sessionKey: string; text: string; + attachments?: AcpTurnAttachment[]; mode: AcpRuntimePromptMode; requestId: string; signal?: AbortSignal; diff --git a/src/acp/runtime/types.ts b/src/acp/runtime/types.ts index 6a3d3bb3f8e38..2d4b10ccf2ca8 100644 --- a/src/acp/runtime/types.ts +++ b/src/acp/runtime/types.ts @@ -39,9 +39,15 @@ export type AcpRuntimeEnsureInput = { env?: Record; }; +export type AcpRuntimeTurnAttachment = { + mediaType: string; + data: string; +}; + export type AcpRuntimeTurnInput = { handle: AcpRuntimeHandle; text: string; + attachments?: AcpRuntimeTurnAttachment[]; mode: AcpRuntimePromptMode; requestId: string; signal?: AbortSignal; diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 286b73a7cebb0..290846a60756d 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -1,3 +1,6 @@ +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 { AcpRuntimeError } from "../../acp/runtime/errors.js"; import type { AcpSessionStoreEntry } from "../../acp/runtime/session-meta.js"; @@ -131,6 +134,7 @@ async function runDispatch(params: { dispatcher?: ReplyDispatcher; shouldRouteToOriginating?: boolean; onReplyStart?: () => void; + ctxOverrides?: Record; }) { return tryDispatchAcpReply({ ctx: buildTestCtx({ @@ -138,6 +142,7 @@ async function runDispatch(params: { Surface: "discord", SessionKey: sessionKey, BodyForAgent: params.bodyForAgent, + ...params.ctxOverrides, }), cfg: params.cfg ?? createAcpTestConfig(), dispatcher: params.dispatcher ?? createDispatcher().dispatcher, @@ -353,6 +358,34 @@ describe("tryDispatchAcpReply", () => { expect(onReplyStart).not.toHaveBeenCalled(); }); + it("forwards normalized image attachments into ACP turns", async () => { + setReadyAcpResolution(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-")); + const imagePath = path.join(tempDir, "inbound.png"); + await fs.writeFile(imagePath, "image-bytes"); + managerMocks.runTurn.mockResolvedValue(undefined); + + await runDispatch({ + bodyForAgent: " ", + ctxOverrides: { + MediaPath: imagePath, + MediaType: "image/png", + }, + }); + + expect(managerMocks.runTurn).toHaveBeenCalledWith( + expect.objectContaining({ + text: "", + attachments: [ + { + mediaType: "image/png", + data: Buffer.from("image-bytes").toString("base64"), + }, + ], + }), + ); + }); + it("surfaces ACP policy errors as final error replies", async () => { setReadyAcpResolution(); policyMocks.resolveAcpDispatchPolicyError.mockReturnValue( diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 33990cb20d69b..3b89feaae133b 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -1,4 +1,6 @@ +import fs from "node:fs/promises"; import { getAcpSessionManager } from "../../acp/control-plane/manager.js"; +import type { AcpTurnAttachment } from "../../acp/control-plane/manager.types.js"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js"; import { formatAcpRuntimeErrorText } from "../../acp/runtime/error-text.js"; import { toAcpRuntimeError } from "../../acp/runtime/errors.js"; @@ -14,6 +16,10 @@ import { logVerbose } from "../../globals.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { prefixSystemMessage } from "../../infra/system-message.js"; +import { + normalizeAttachmentPath, + normalizeAttachments, +} from "../../media-understanding/attachments.normalize.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { maybeApplyTtsToPayload, resolveTtsConfig } from "../../tts/tts.js"; import { @@ -57,6 +63,36 @@ function resolveAcpPromptText(ctx: FinalizedMsgContext): string { ]).trim(); } +const ACP_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; + +async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise { + const mediaAttachments = normalizeAttachments(ctx); + const results: AcpTurnAttachment[] = []; + for (const attachment of mediaAttachments) { + const filePath = normalizeAttachmentPath(attachment.path); + if (!filePath) { + continue; + } + try { + const stat = await fs.stat(filePath); + if (stat.size > ACP_ATTACHMENT_MAX_BYTES) { + logVerbose( + `dispatch-acp: skipping attachment ${filePath} (${stat.size} bytes exceeds ${ACP_ATTACHMENT_MAX_BYTES} byte limit)`, + ); + continue; + } + const buf = await fs.readFile(filePath); + results.push({ + mediaType: attachment.mime ?? "application/octet-stream", + data: buf.toString("base64"), + }); + } catch { + // Skip unreadable files. Text content should still be delivered. + } + } + return results; +} + function resolveCommandCandidateText(ctx: FinalizedMsgContext): string { return resolveFirstContextText(ctx, ["CommandBody", "BodyForCommands", "RawBody", "Body"]).trim(); } @@ -189,7 +225,8 @@ export async function tryDispatchAcpReply(params: { }); const promptText = resolveAcpPromptText(params.ctx); - if (!promptText) { + const attachments = await resolveAcpAttachments(params.ctx); + if (!promptText && attachments.length === 0) { const counts = params.dispatcher.getQueuedCounts(); delivery.applyRoutedCounts(counts); params.recordProcessed("completed", { reason: "acp_empty_prompt" }); @@ -251,6 +288,7 @@ export async function tryDispatchAcpReply(params: { cfg: params.cfg, sessionKey, text: promptText, + attachments: attachments.length > 0 ? attachments : undefined, mode: "prompt", requestId: resolveAcpRequestId(params.ctx), onEvent: async (event) => await projector.onEvent(event), From 0c7f07818f0eec0f4c527233019fd0d504d09804 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:40:14 +0100 Subject: [PATCH 288/311] acp: add regression coverage and smoke-test docs (#41456) Merged via squash. Prepared head SHA: 514d5873520683efcca1542cbca1ee6ec645582b Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + docs/tools/acp-agents.md | 40 +++++++++++++++++++ extensions/acpx/src/runtime.test.ts | 33 +++++++++++++++ ...sessions.gateway-server-sessions-a.test.ts | 12 ++++++ 4 files changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0a735cb1879..dfa23b105af18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. - ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. - ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. +- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. ## 2026.3.8 diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 74ed73248f132..e41a96248aeab 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -246,6 +246,46 @@ Interface details: - `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events. - When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`.acp-stream.jsonl`) you can tail for full relay history. +### Operator smoke test + +Use this after a gateway deploy when you want a quick live check that ACP spawn +is actually working end-to-end, not just passing unit tests. + +Recommended gate: + +1. Verify the deployed gateway version/commit on the target host. +2. Confirm the deployed source includes the ACP lineage acceptance in + `src/gateway/sessions-patch.ts` (`subagent:* or acp:* sessions`). +3. Open a temporary ACPX bridge session to a live agent (for example + `razor(main)` on `jpclawhq`). +4. Ask that agent to call `sessions_spawn` with: + - `runtime: "acp"` + - `agentId: "codex"` + - `mode: "run"` + - task: `Reply with exactly LIVE-ACP-SPAWN-OK` +5. Verify the agent reports: + - `accepted=yes` + - a real `childSessionKey` + - no validator error +6. Clean up the temporary ACPX bridge session. + +Example prompt to the live agent: + +```text +Use the sessions_spawn tool now with runtime: "acp", agentId: "codex", and mode: "run". +Set the task to: "Reply with exactly LIVE-ACP-SPAWN-OK". +Then report only: accepted=; childSessionKey=; error=. +``` + +Notes: + +- Keep this smoke test on `mode: "run"` unless you are intentionally testing + thread-bound persistent ACP sessions. +- Do not require `streamTo: "parent"` for the basic gate. That path depends on + requester/session capabilities and is a separate integration check. +- Treat thread-bound `mode: "session"` testing as a second, richer integration + pass from a real Discord thread or Telegram topic. + ## Sandbox compatibility ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox. diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index bb3b94cec9e58..38137b3f58168 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -127,6 +127,39 @@ describe("AcpxRuntime", () => { expect(promptArgs).toContain("--approve-all"); }); + it("serializes text plus image attachments into ACP prompt blocks", async () => { + const { runtime, logPath } = await createMockRuntimeFixture(); + + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:with-image", + agent: "codex", + mode: "persistent", + }); + + for await (const _event of runtime.runTurn({ + handle, + text: "describe this image", + attachments: [{ mediaType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }], + mode: "prompt", + requestId: "req-image", + })) { + // Consume stream to completion so prompt logging is finalized. + } + + const logs = await readMockRuntimeLogEntries(logPath); + const prompt = logs.find( + (entry) => + entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:with-image", + ); + expect(prompt).toBeDefined(); + + const stdinBlocks = JSON.parse(String(prompt?.stdinText ?? "")); + expect(stdinBlocks).toEqual([ + { type: "text", text: "describe this image" }, + { type: "image", mimeType: "image/png", data: "aW1hZ2UtYnl0ZXM=" }, + ]); + }); + it("preserves leading spaces across streamed text deltas", async () => { const runtime = sharedFixture?.runtime; expect(runtime).toBeDefined(); 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 3837247c9bc27..f986d49c648bb 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -463,6 +463,18 @@ describe("gateway server sessions", () => { expect(spawnedPatched.ok).toBe(true); expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); + const acpPatched = await rpcReq<{ + ok: true; + entry: { spawnedBy?: string; spawnDepth?: number }; + }>(ws, "sessions.patch", { + key: "agent:main:acp:child", + spawnedBy: "agent:main:main", + spawnDepth: 1, + }); + expect(acpPatched.ok).toBe(true); + expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); + expect(acpPatched.payload?.entry.spawnDepth).toBe(1); + const spawnedPatchedInvalidKey = await rpcReq(ws, "sessions.patch", { key: "agent:main:main", spawnedBy: "agent:main:main", From 0669b0ddc265742009195eb9f1e9b6e93efb8c02 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 00:58:51 +0300 Subject: [PATCH 289/311] fix(agents): probe single-provider billing cooldowns (#41422) Merged via squash. Prepared head SHA: bbc4254b94559f95c34e11734a679cbe852aba52 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/model-fallback.probe.test.ts | 60 ++++++++++++++---- src/agents/model-fallback.ts | 61 ++++++++++++++++--- ...pi-agent.auth-profile-rotation.e2e.test.ts | 48 +++++++++++++++ 4 files changed, 152 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa23b105af18..8b140351b5d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky. - ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. - ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. +- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. ## 2026.3.8 diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 01bcb2dc3a866..9426eba6afcfa 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -251,6 +251,36 @@ describe("runWithModelFallback – probe logic", () => { expectPrimaryProbeSuccess(result, run, "probed-ok"); }); + it("prunes stale probe throttle entries before checking eligibility", () => { + _probeThrottleInternals.lastProbeAttempt.set( + "stale", + NOW - _probeThrottleInternals.PROBE_STATE_TTL_MS - 1, + ); + _probeThrottleInternals.lastProbeAttempt.set("fresh", NOW - 5_000); + + expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(true); + + expect(_probeThrottleInternals.isProbeThrottleOpen(NOW, "fresh")).toBe(false); + + expect(_probeThrottleInternals.lastProbeAttempt.has("stale")).toBe(false); + expect(_probeThrottleInternals.lastProbeAttempt.has("fresh")).toBe(true); + }); + + it("caps probe throttle state by evicting the oldest entries", () => { + for (let i = 0; i < _probeThrottleInternals.MAX_PROBE_KEYS; i += 1) { + _probeThrottleInternals.lastProbeAttempt.set(`key-${i}`, NOW - (i + 1)); + } + + _probeThrottleInternals.markProbeAttempt(NOW, "freshest"); + + expect(_probeThrottleInternals.lastProbeAttempt.size).toBe( + _probeThrottleInternals.MAX_PROBE_KEYS, + ); + expect(_probeThrottleInternals.lastProbeAttempt.has("freshest")).toBe(true); + expect(_probeThrottleInternals.lastProbeAttempt.has("key-255")).toBe(false); + expect(_probeThrottleInternals.lastProbeAttempt.has("key-0")).toBe(true); + }); + it("handles non-finite soonest safely (treats as probe-worthy)", async () => { const cfg = makeCfg(); @@ -346,7 +376,7 @@ describe("runWithModelFallback – probe logic", () => { }); }); - it("skips billing-cooldowned primary when no fallback candidates exist", async () => { + it("probes billing-cooldowned primary when no fallback candidates exist", async () => { const cfg = makeCfg({ agents: { defaults: { @@ -358,20 +388,28 @@ describe("runWithModelFallback – probe logic", () => { }, } as Partial); - // Billing cooldown far from expiry — would normally be skipped + // Single-provider setups need periodic probes even when the billing + // cooldown is far from expiry, otherwise topping up credits never recovers + // without a restart. const expiresIn30Min = NOW + 30 * 60 * 1000; mockedGetSoonestCooldownExpiry.mockReturnValue(expiresIn30Min); mockedResolveProfilesUnavailableReason.mockReturnValue("billing"); - await expect( - runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - fallbacksOverride: [], - run: vi.fn().mockResolvedValue("billing-recovered"), - }), - ).rejects.toThrow("All models failed"); + const run = vi.fn().mockResolvedValue("billing-recovered"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + fallbacksOverride: [], + run, + }); + + expect(result.result).toBe("billing-recovered"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); }); it("probes billing-cooldowned primary with fallbacks when near cooldown expiry", async () => { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index ad2b5759233fe..b9ff9d668ff3a 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -342,12 +342,51 @@ const lastProbeAttempt = new Map(); const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key const PROBE_MARGIN_MS = 2 * 60 * 1000; const PROBE_SCOPE_DELIMITER = "::"; +const PROBE_STATE_TTL_MS = 24 * 60 * 60 * 1000; +const MAX_PROBE_KEYS = 256; function resolveProbeThrottleKey(provider: string, agentDir?: string): string { const scope = String(agentDir ?? "").trim(); return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider; } +function pruneProbeState(now: number): void { + for (const [key, ts] of lastProbeAttempt) { + if (!Number.isFinite(ts) || ts <= 0 || now - ts > PROBE_STATE_TTL_MS) { + lastProbeAttempt.delete(key); + } + } +} + +function enforceProbeStateCap(): void { + while (lastProbeAttempt.size > MAX_PROBE_KEYS) { + let oldestKey: string | null = null; + let oldestTs = Number.POSITIVE_INFINITY; + for (const [key, ts] of lastProbeAttempt) { + if (ts < oldestTs) { + oldestKey = key; + oldestTs = ts; + } + } + if (!oldestKey) { + break; + } + lastProbeAttempt.delete(oldestKey); + } +} + +function isProbeThrottleOpen(now: number, throttleKey: string): boolean { + pruneProbeState(now); + const lastProbe = lastProbeAttempt.get(throttleKey) ?? 0; + return now - lastProbe >= MIN_PROBE_INTERVAL_MS; +} + +function markProbeAttempt(now: number, throttleKey: string): void { + pruneProbeState(now); + lastProbeAttempt.set(throttleKey, now); + enforceProbeStateCap(); +} + function shouldProbePrimaryDuringCooldown(params: { isPrimary: boolean; hasFallbackCandidates: boolean; @@ -360,8 +399,7 @@ function shouldProbePrimaryDuringCooldown(params: { return false; } - const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0; - if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) { + if (!isProbeThrottleOpen(params.now, params.throttleKey)) { return false; } @@ -379,7 +417,12 @@ export const _probeThrottleInternals = { lastProbeAttempt, MIN_PROBE_INTERVAL_MS, PROBE_MARGIN_MS, + PROBE_STATE_TTL_MS, + MAX_PROBE_KEYS, resolveProbeThrottleKey, + isProbeThrottleOpen, + pruneProbeState, + markProbeAttempt, } as const; type CooldownDecision = @@ -429,11 +472,15 @@ function resolveCooldownDecision(params: { } // Billing is semi-persistent: the user may fix their balance, or a transient - // 402 might have been misclassified. Probe the primary only when fallbacks - // exist; otherwise repeated single-provider probes just churn the disabled - // auth state without opening any recovery path. + // 402 might have been misclassified. Probe single-provider setups on the + // standard throttle so they can recover without a restart; when fallbacks + // exist, only probe near cooldown expiry so the fallback chain stays preferred. if (inferredReason === "billing") { - if (params.isPrimary && params.hasFallbackCandidates && shouldProbe) { + const shouldProbeSingleProviderBilling = + params.isPrimary && + !params.hasFallbackCandidates && + isProbeThrottleOpen(params.now, params.probeThrottleKey); + if (params.isPrimary && (shouldProbe || shouldProbeSingleProviderBilling)) { return { type: "attempt", reason: inferredReason, markProbe: true }; } return { @@ -528,7 +575,7 @@ export async function runWithModelFallback(params: { } if (decision.markProbe) { - lastProbeAttempt.set(probeThrottleKey, now); + markProbeAttempt(now, probeThrottleKey); } if ( decision.reason === "rate_limit" || diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 75ce17eb197dc..432ae17daa19b 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -1013,6 +1013,54 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { }); }); + it("can probe one billing-disabled profile when transient cooldown probe is allowed without fallback models", async () => { + await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { + await writeAuthStore(agentDir, { + usageStats: { + "openai:p1": { + lastUsed: 1, + disabledUntil: now + 60 * 60 * 1000, + disabledReason: "billing", + }, + "openai:p2": { + lastUsed: 2, + disabledUntil: now + 60 * 60 * 1000, + disabledReason: "billing", + }, + }, + }); + + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + const result = await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileIdSource: "auto", + allowTransientCooldownProbe: true, + timeoutMs: 5_000, + runId: "run:billing-cooldown-probe-no-fallbacks", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.text ?? "").toContain("ok"); + }); + }); + it("treats agent-level fallbacks as configured when defaults have none", async () => { await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => { await writeAuthStore(agentDir, { From 3c3474360be81d53652f9f4f93bfbe5d72a80ddc Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:03:50 +0100 Subject: [PATCH 290/311] acp: harden follow-up reliability and attachments (#41464) Merged via squash. Prepared head SHA: 7d167dff54ab975f90224feb3fe697a5e508e895 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/acp/event-mapper.test.ts | 18 +++ src/acp/event-mapper.ts | 18 +-- src/acp/translator.session-rate-limit.test.ts | 112 ++++++++++++++++++ src/acp/translator.ts | 16 ++- src/auto-reply/reply/dispatch-acp.test.ts | 70 +++++++---- src/auto-reply/reply/dispatch-acp.ts | 39 ++++-- 7 files changed, 230 insertions(+), 44 deletions(-) create mode 100644 src/acp/event-mapper.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b140351b5d2d..93483d148d0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky. - ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. - Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. +- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. ## 2026.3.8 diff --git a/src/acp/event-mapper.test.ts b/src/acp/event-mapper.test.ts new file mode 100644 index 0000000000000..2aca401d483b3 --- /dev/null +++ b/src/acp/event-mapper.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { extractToolCallLocations } from "./event-mapper.js"; + +describe("extractToolCallLocations", () => { + it("enforces the global node visit cap across nested structures", () => { + const nested = Array.from({ length: 20 }, (_, outer) => + Array.from({ length: 20 }, (_, inner) => + inner === 19 ? { path: `/tmp/file-${outer}.txt` } : { note: `${outer}-${inner}` }, + ), + ); + + const locations = extractToolCallLocations(nested); + + expect(locations).toBeDefined(); + expect(locations?.length).toBeLessThan(20); + expect(locations).not.toContainEqual({ path: "/tmp/file-19.txt" }); + }); +}); diff --git a/src/acp/event-mapper.ts b/src/acp/event-mapper.ts index 2a74f5691cfcc..c164f356307bb 100644 --- a/src/acp/event-mapper.ts +++ b/src/acp/event-mapper.ts @@ -186,9 +186,10 @@ function collectLocationsFromTextMarkers( function collectToolLocations( value: unknown, locations: Map, - state: { visited: number; depth: number }, + state: { visited: number }, + depth: number, ): void { - if (state.visited >= TOOL_LOCATION_MAX_NODES || state.depth > TOOL_LOCATION_MAX_DEPTH) { + if (state.visited >= TOOL_LOCATION_MAX_NODES || depth > TOOL_LOCATION_MAX_DEPTH) { return; } state.visited += 1; @@ -202,8 +203,7 @@ function collectToolLocations( } if (Array.isArray(value)) { for (const item of value) { - collectToolLocations(item, locations, { visited: state.visited, depth: state.depth + 1 }); - state.visited += 1; + collectToolLocations(item, locations, state, depth + 1); if (state.visited >= TOOL_LOCATION_MAX_NODES) { return; } @@ -230,9 +230,11 @@ function collectToolLocations( } } - for (const nested of Object.values(record)) { - collectToolLocations(nested, locations, { visited: state.visited, depth: state.depth + 1 }); - state.visited += 1; + for (const [key, nested] of Object.entries(record)) { + if (key === "content") { + continue; + } + collectToolLocations(nested, locations, state, depth + 1); if (state.visited >= TOOL_LOCATION_MAX_NODES) { return; } @@ -402,7 +404,7 @@ export function extractToolCallContent(value: unknown): ToolCallContent[] | unde export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined { const locations = new Map(); for (const value of values) { - collectToolLocations(value, locations, { visited: 0, depth: 0 }); + collectToolLocations(value, locations, { visited: 0 }, 0); } return locations.size > 0 ? [...locations.values()] : undefined; } diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index a591d30e1ac56..d08ae1a1567ba 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -365,6 +365,63 @@ describe("acp session UX bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + + it("falls back to an empty transcript when sessions.get fails during loadSession", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "agent:main:recover", + label: "recover", + displayName: "Recover session", + kind: "direct", + updatedAt: 1_710_000_000_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + }, + ], + }; + } + if (method === "sessions.get") { + throw new Error("sessions.get unavailable"); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + const result = await agent.loadSession(createLoadSessionRequest("agent:main:recover")); + + expect(result.modes?.currentModeId).toBe("adaptive"); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "agent:main:recover", + update: expect.objectContaining({ + sessionUpdate: "available_commands_update", + }), + }); + expect(sessionUpdate).not.toHaveBeenCalledWith({ + sessionId: "agent:main:recover", + update: expect.objectContaining({ + sessionUpdate: "user_message_chunk", + }), + }); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp setSessionMode bridge behavior", () => { @@ -771,6 +828,61 @@ describe("acp session metadata and usage updates", () => { sessionStore.clearAllSessionsForTest(); }); + + it("still resolves prompts when snapshot updates fail after completion", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "usage-session", + displayName: "Usage session", + kind: "direct", + updatedAt: 1_710_000_123_000, + thinkingLevel: "adaptive", + modelProvider: "openai", + model: "gpt-5.4", + totalTokens: 1200, + totalTokensFresh: true, + contextTokens: 4000, + }, + ], + }; + } + if (method === "chat.send") { + return new Promise(() => {}); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("usage-session")); + sessionUpdate.mockClear(); + sessionUpdate.mockRejectedValueOnce(new Error("session update transport failed")); + + const promptPromise = agent.prompt(createPromptRequest("usage-session", "hello")); + await agent.handleGatewayEvent(createChatFinalEvent("usage-session")); + + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + const session = sessionStore.getSession("usage-session"); + expect(session?.activeRunId).toBeNull(); + expect(session?.abortController).toBeNull(); + + sessionStore.clearAllSessionsForTest(); + }); }); describe("acp prompt size hardening", () => { diff --git a/src/acp/translator.ts b/src/acp/translator.ts index e7fa4a7382ebe..667c075e9c0e7 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -458,7 +458,10 @@ export class AcpGatewayAgent implements Agent { this.log(`loadSession: ${session.sessionId} -> ${session.sessionKey}`); const [sessionSnapshot, transcript] = await Promise.all([ this.getSessionSnapshot(session.sessionKey), - this.getSessionTranscript(session.sessionKey), + this.getSessionTranscript(session.sessionKey).catch((err) => { + this.log(`session transcript fallback for ${session.sessionKey}: ${String(err)}`); + return []; + }), ]); await this.replaySessionTranscript(session.sessionId, transcript); await this.sendSessionSnapshotUpdate(session.sessionId, sessionSnapshot, { @@ -630,7 +633,6 @@ export class AcpGatewayAgent implements Agent { if (!session) { return; } - this.sessionStore.cancelActiveRun(params.sessionId); try { await this.gateway.request("chat.abort", { sessionKey: session.sessionKey }); @@ -841,9 +843,13 @@ export class AcpGatewayAgent implements Agent { this.pendingPrompts.delete(sessionId); this.sessionStore.clearActiveRun(sessionId); const sessionSnapshot = await this.getSessionSnapshot(pending.sessionKey); - await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { - includeControls: false, - }); + try { + await this.sendSessionSnapshotUpdate(sessionId, sessionSnapshot, { + includeControls: false, + }); + } catch (err) { + this.log(`session snapshot update failed for ${sessionId}: ${String(err)}`); + } pending.resolve({ stopReason }); } diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 290846a60756d..b19f2edde09f0 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -362,28 +362,58 @@ describe("tryDispatchAcpReply", () => { setReadyAcpResolution(); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-")); const imagePath = path.join(tempDir, "inbound.png"); - await fs.writeFile(imagePath, "image-bytes"); - managerMocks.runTurn.mockResolvedValue(undefined); + try { + await fs.writeFile(imagePath, "image-bytes"); + managerMocks.runTurn.mockResolvedValue(undefined); + + await runDispatch({ + bodyForAgent: " ", + ctxOverrides: { + MediaPath: imagePath, + MediaType: "image/png", + }, + }); + + expect(managerMocks.runTurn).toHaveBeenCalledWith( + expect.objectContaining({ + text: "", + attachments: [ + { + mediaType: "image/png", + data: Buffer.from("image-bytes").toString("base64"), + }, + ], + }), + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); - await runDispatch({ - bodyForAgent: " ", - ctxOverrides: { - MediaPath: imagePath, - MediaType: "image/png", - }, - }); + it("skips ACP turns for non-image attachments when there is no text prompt", async () => { + setReadyAcpResolution(); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-")); + const docPath = path.join(tempDir, "inbound.pdf"); + const { dispatcher } = createDispatcher(); + const onReplyStart = vi.fn(); + try { + await fs.writeFile(docPath, "pdf-bytes"); + + await runDispatch({ + bodyForAgent: " ", + dispatcher, + onReplyStart, + ctxOverrides: { + MediaPath: docPath, + MediaType: "application/pdf", + }, + }); - expect(managerMocks.runTurn).toHaveBeenCalledWith( - expect.objectContaining({ - text: "", - attachments: [ - { - mediaType: "image/png", - data: Buffer.from("image-bytes").toString("base64"), - }, - ], - }), - ); + expect(managerMocks.runTurn).not.toHaveBeenCalled(); + expect(onReplyStart).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); it("surfaces ACP policy errors as final error replies", async () => { diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 3b89feaae133b..8fc7110fc4cea 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -16,6 +16,7 @@ import { logVerbose } from "../../globals.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { generateSecureUuid } from "../../infra/secure-random.js"; import { prefixSystemMessage } from "../../infra/system-message.js"; +import { applyMediaUnderstanding } from "../../media-understanding/apply.js"; import { normalizeAttachmentPath, normalizeAttachments, @@ -69,6 +70,10 @@ async function resolveAcpAttachments(ctx: FinalizedMsgContext): Promise Date: Tue, 10 Mar 2026 01:12:10 +0300 Subject: [PATCH 291/311] Agents: add fallback error observations (#41337) Merged via squash. Prepared head SHA: 852469c82ff28fb0e1be7f1019f5283e712c4283 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../auth-profiles/state-observation.test.ts | 38 +++++++ src/agents/auth-profiles/state-observation.ts | 59 ++++++++++ src/agents/auth-profiles/usage.ts | 64 ++++++++--- src/agents/model-fallback-observation.ts | 93 ++++++++++++++++ src/agents/model-fallback.probe.test.ts | 101 ++++++++++++++++++ .../model-fallback.run-embedded.e2e.test.ts | 1 + src/agents/model-fallback.test.ts | 4 +- src/agents/model-fallback.ts | 89 ++++++++++++--- src/agents/model-fallback.types.ts | 15 +++ ...pi-agent.auth-profile-rotation.e2e.test.ts | 67 +++++++++++- src/agents/pi-embedded-runner/run.ts | 1 + .../reply/agent-runner-execution.ts | 1 + src/auto-reply/reply/agent-runner-memory.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + src/commands/agent.ts | 1 + src/cron/isolated-agent/run.ts | 1 + 17 files changed, 502 insertions(+), 36 deletions(-) create mode 100644 src/agents/auth-profiles/state-observation.test.ts create mode 100644 src/agents/auth-profiles/state-observation.ts create mode 100644 src/agents/model-fallback-observation.ts create mode 100644 src/agents/model-fallback.types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93483d148d0bc..f1724d98c8ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky. - Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. +- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. ## 2026.3.8 diff --git a/src/agents/auth-profiles/state-observation.test.ts b/src/agents/auth-profiles/state-observation.test.ts new file mode 100644 index 0000000000000..05f2abfff19cc --- /dev/null +++ b/src/agents/auth-profiles/state-observation.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetLogger, setLoggerOverride } from "../../logging/logger.js"; +import { logAuthProfileFailureStateChange } from "./state-observation.js"; + +afterEach(() => { + setLoggerOverride(null); + resetLogger(); +}); + +describe("logAuthProfileFailureStateChange", () => { + it("sanitizes consoleMessage fields before logging", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + + logAuthProfileFailureStateChange({ + runId: "run-1\nforged\tentry\rtest", + profileId: "openai:profile-1", + provider: "openai\u001b]8;;https://evil.test\u0007", + reason: "overloaded", + previous: undefined, + next: { + errorCount: 1, + cooldownUntil: 1_700_000_060_000, + failureCounts: { overloaded: 1 }, + }, + now: 1_700_000_000_000, + }); + + const consoleLine = warnSpy.mock.calls[0]?.[0]; + expect(typeof consoleLine).toBe("string"); + expect(consoleLine).toContain("runId=run-1 forged entry test"); + expect(consoleLine).toContain("provider=openai]8;;https://evil.test"); + expect(consoleLine).not.toContain("\n"); + expect(consoleLine).not.toContain("\r"); + expect(consoleLine).not.toContain("\t"); + expect(consoleLine).not.toContain("\u001b"); + }); +}); diff --git a/src/agents/auth-profiles/state-observation.ts b/src/agents/auth-profiles/state-observation.ts new file mode 100644 index 0000000000000..633bdc0031b96 --- /dev/null +++ b/src/agents/auth-profiles/state-observation.ts @@ -0,0 +1,59 @@ +import { redactIdentifier } from "../../logging/redact-identifier.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { sanitizeForConsole } from "../pi-embedded-error-observation.js"; +import type { AuthProfileFailureReason, ProfileUsageStats } from "./types.js"; + +const observationLog = createSubsystemLogger("agent/embedded"); + +export function logAuthProfileFailureStateChange(params: { + runId?: string; + profileId: string; + provider: string; + reason: AuthProfileFailureReason; + previous: ProfileUsageStats | undefined; + next: ProfileUsageStats; + now: number; +}): void { + const windowType = + params.reason === "billing" || params.reason === "auth_permanent" ? "disabled" : "cooldown"; + const previousCooldownUntil = params.previous?.cooldownUntil; + const previousDisabledUntil = params.previous?.disabledUntil; + // Active cooldown/disable windows are intentionally immutable; log whether this + // update reused the existing window instead of extending it. + const windowReused = + windowType === "disabled" + ? typeof previousDisabledUntil === "number" && + Number.isFinite(previousDisabledUntil) && + previousDisabledUntil > params.now && + previousDisabledUntil === params.next.disabledUntil + : typeof previousCooldownUntil === "number" && + Number.isFinite(previousCooldownUntil) && + previousCooldownUntil > params.now && + previousCooldownUntil === params.next.cooldownUntil; + const safeProfileId = redactIdentifier(params.profileId, { len: 12 }); + const safeRunId = sanitizeForConsole(params.runId) ?? "-"; + const safeProvider = sanitizeForConsole(params.provider) ?? "-"; + + observationLog.warn("auth profile failure state updated", { + event: "auth_profile_failure_state_updated", + tags: ["error_handling", "auth_profiles", windowType], + runId: params.runId, + profileId: safeProfileId, + provider: params.provider, + reason: params.reason, + windowType, + windowReused, + previousErrorCount: params.previous?.errorCount, + errorCount: params.next.errorCount, + previousCooldownUntil, + cooldownUntil: params.next.cooldownUntil, + previousDisabledUntil, + disabledUntil: params.next.disabledUntil, + previousDisabledReason: params.previous?.disabledReason, + disabledReason: params.next.disabledReason, + failureCounts: params.next.failureCounts, + consoleMessage: + `auth profile failure state updated: runId=${safeRunId} profile=${safeProfileId} provider=${safeProvider} ` + + `reason=${params.reason} window=${windowType} reused=${String(windowReused)}`, + }); +} diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 0d9ae6a6aaa9c..273fd7545956f 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import { normalizeProviderId } from "../model-selection.js"; +import { logAuthProfileFailureStateChange } from "./state-observation.js"; import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js"; import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js"; @@ -462,12 +463,16 @@ export async function markAuthProfileFailure(params: { reason: AuthProfileFailureReason; cfg?: OpenClawConfig; agentDir?: string; + runId?: string; }): Promise { - const { store, profileId, reason, agentDir, cfg } = params; + const { store, profileId, reason, agentDir, cfg, runId } = params; const profile = store.profiles[profileId]; if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { return; } + let nextStats: ProfileUsageStats | undefined; + let previousStats: ProfileUsageStats | undefined; + let updateTime = 0; const updated = await updateAuthProfileStoreWithLock({ agentDir, updater: (freshStore) => { @@ -482,19 +487,32 @@ export async function markAuthProfileFailure(params: { providerId: providerKey, }); - updateUsageStatsEntry(freshStore, profileId, (existing) => - computeNextProfileUsageStats({ - existing: existing ?? {}, - now, - reason, - cfgResolved, - }), - ); + previousStats = freshStore.usageStats?.[profileId]; + updateTime = now; + const computed = computeNextProfileUsageStats({ + existing: previousStats ?? {}, + now, + reason, + cfgResolved, + }); + nextStats = computed; + updateUsageStatsEntry(freshStore, profileId, () => computed); return true; }, }); if (updated) { store.usageStats = updated.usageStats; + if (nextStats) { + logAuthProfileFailureStateChange({ + runId, + profileId, + provider: profile.provider, + reason, + previous: previousStats, + next: nextStats, + now: updateTime, + }); + } return; } if (!store.profiles[profileId]) { @@ -508,15 +526,25 @@ export async function markAuthProfileFailure(params: { providerId: providerKey, }); - updateUsageStatsEntry(store, profileId, (existing) => - computeNextProfileUsageStats({ - existing: existing ?? {}, - now, - reason, - cfgResolved, - }), - ); + previousStats = store.usageStats?.[profileId]; + const computed = computeNextProfileUsageStats({ + existing: previousStats ?? {}, + now, + reason, + cfgResolved, + }); + nextStats = computed; + updateUsageStatsEntry(store, profileId, () => computed); saveAuthProfileStore(store, agentDir); + logAuthProfileFailureStateChange({ + runId, + profileId, + provider: store.profiles[profileId]?.provider ?? profile.provider, + reason, + previous: previousStats, + next: nextStats, + now, + }); } /** @@ -528,12 +556,14 @@ export async function markAuthProfileCooldown(params: { store: AuthProfileStore; profileId: string; agentDir?: string; + runId?: string; }): Promise { await markAuthProfileFailure({ store: params.store, profileId: params.profileId, reason: "unknown", agentDir: params.agentDir, + runId: params.runId, }); } diff --git a/src/agents/model-fallback-observation.ts b/src/agents/model-fallback-observation.ts new file mode 100644 index 0000000000000..450e047c7d7d8 --- /dev/null +++ b/src/agents/model-fallback-observation.ts @@ -0,0 +1,93 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; +import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js"; +import { buildTextObservationFields } from "./pi-embedded-error-observation.js"; +import type { FailoverReason } from "./pi-embedded-helpers.js"; + +const decisionLog = createSubsystemLogger("model-fallback").child("decision"); + +function buildErrorObservationFields(error?: string): { + errorPreview?: string; + errorHash?: string; + errorFingerprint?: string; + httpCode?: string; + providerErrorType?: string; + providerErrorMessagePreview?: string; + requestIdHash?: string; +} { + const observed = buildTextObservationFields(error); + return { + errorPreview: observed.textPreview, + errorHash: observed.textHash, + errorFingerprint: observed.textFingerprint, + httpCode: observed.httpCode, + providerErrorType: observed.providerErrorType, + providerErrorMessagePreview: observed.providerErrorMessagePreview, + requestIdHash: observed.requestIdHash, + }; +} + +export function logModelFallbackDecision(params: { + decision: + | "skip_candidate" + | "probe_cooldown_candidate" + | "candidate_failed" + | "candidate_succeeded"; + runId?: string; + requestedProvider: string; + requestedModel: string; + candidate: ModelCandidate; + attempt?: number; + total?: number; + reason?: FailoverReason | null; + status?: number; + code?: string; + error?: string; + nextCandidate?: ModelCandidate; + isPrimary?: boolean; + requestedModelMatched?: boolean; + fallbackConfigured?: boolean; + allowTransientCooldownProbe?: boolean; + profileCount?: number; + previousAttempts?: FallbackAttempt[]; +}): void { + const nextText = params.nextCandidate + ? `${sanitizeForLog(params.nextCandidate.provider)}/${sanitizeForLog(params.nextCandidate.model)}` + : "none"; + const reasonText = params.reason ?? "unknown"; + const observedError = buildErrorObservationFields(params.error); + decisionLog.warn("model fallback decision", { + event: "model_fallback_decision", + tags: ["error_handling", "model_fallback", params.decision], + runId: params.runId, + decision: params.decision, + requestedProvider: params.requestedProvider, + requestedModel: params.requestedModel, + candidateProvider: params.candidate.provider, + candidateModel: params.candidate.model, + attempt: params.attempt, + total: params.total, + reason: params.reason, + status: params.status, + code: params.code, + ...observedError, + nextCandidateProvider: params.nextCandidate?.provider, + nextCandidateModel: params.nextCandidate?.model, + isPrimary: params.isPrimary, + requestedModelMatched: params.requestedModelMatched, + fallbackConfigured: params.fallbackConfigured, + allowTransientCooldownProbe: params.allowTransientCooldownProbe, + profileCount: params.profileCount, + previousAttempts: params.previousAttempts?.map((attempt) => ({ + provider: attempt.provider, + model: attempt.model, + reason: attempt.reason, + status: attempt.status, + code: attempt.code, + ...buildErrorObservationFields(attempt.error), + })), + consoleMessage: + `model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` + + `candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}`, + }); +} diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 9426eba6afcfa..d08bd0d4beb88 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -1,5 +1,8 @@ +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; import type { AuthProfileStore } from "./auth-profiles.js"; import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js"; @@ -28,6 +31,7 @@ const mockedResolveProfilesUnavailableReason = vi.mocked(resolveProfilesUnavaila const mockedResolveAuthProfileOrder = vi.mocked(resolveAuthProfileOrder); const makeCfg = makeModelFallbackCfg; +let unregisterLogTransport: (() => void) | undefined; function expectFallbackUsed( result: { result: unknown; attempts: Array<{ reason?: string }> }, @@ -149,6 +153,10 @@ describe("runWithModelFallback – probe logic", () => { afterEach(() => { Date.now = realDateNow; + unregisterLogTransport?.(); + unregisterLogTransport = undefined; + setLoggerOverride(null); + resetLogger(); vi.restoreAllMocks(); }); @@ -194,6 +202,99 @@ describe("runWithModelFallback – probe logic", () => { expectPrimaryProbeSuccess(result, run, "probed-ok"); }); + it("logs primary metadata on probe success and failure fallback decisions", async () => { + const cfg = makeCfg(); + const records: Array> = []; + mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000); + setLoggerOverride({ + level: "trace", + consoleLevel: "silent", + file: path.join(os.tmpdir(), `openclaw-model-fallback-probe-${Date.now()}.log`), + }); + unregisterLogTransport = registerLogTransport((record) => { + records.push(record); + }); + + const run = vi.fn().mockResolvedValue("probed-ok"); + + const result = await runPrimaryCandidate(cfg, run); + + expectPrimaryProbeSuccess(result, run, "probed-ok"); + + _probeThrottleInternals.lastProbeAttempt.clear(); + + const fallbackCfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], + }, + }, + }, + } as Partial); + mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 60 * 1000); + const fallbackRun = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) + .mockResolvedValueOnce("fallback-ok"); + + const fallbackResult = await runPrimaryCandidate(fallbackCfg, fallbackRun); + + expect(fallbackResult.result).toBe("fallback-ok"); + expect(fallbackRun).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); + expect(fallbackRun).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5"); + + const decisionPayloads = records + .filter( + (record) => + record["2"] === "model fallback decision" && + record["1"] && + typeof record["1"] === "object", + ) + .map((record) => record["1"] as Record); + + expect(decisionPayloads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "model_fallback_decision", + decision: "probe_cooldown_candidate", + candidateProvider: "openai", + candidateModel: "gpt-4.1-mini", + allowTransientCooldownProbe: true, + }), + expect.objectContaining({ + event: "model_fallback_decision", + decision: "candidate_succeeded", + candidateProvider: "openai", + candidateModel: "gpt-4.1-mini", + isPrimary: true, + requestedModelMatched: true, + }), + expect.objectContaining({ + event: "model_fallback_decision", + decision: "candidate_failed", + candidateProvider: "openai", + candidateModel: "gpt-4.1-mini", + isPrimary: true, + requestedModelMatched: true, + nextCandidateProvider: "anthropic", + nextCandidateModel: "claude-haiku-3-5", + }), + expect.objectContaining({ + event: "model_fallback_decision", + decision: "candidate_succeeded", + candidateProvider: "anthropic", + candidateModel: "claude-haiku-3-5", + isPrimary: false, + requestedModelMatched: false, + }), + ]), + ); + }); + it("probes primary model when cooldown already expired", async () => { const cfg = makeCfg(); // Cooldown expired 5 min ago diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 2e5a8202e9592..504b1457143af 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -207,6 +207,7 @@ async function runEmbeddedFallback(params: { cfg, provider: "openai", model: "mock-1", + runId: params.runId, agentDir: params.agentDir, run: (provider, model, options) => runEmbeddedPiAgent({ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index c99d0a9bed93e..e4c84028e95e6 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -536,7 +536,9 @@ describe("runWithModelFallback", () => { }); expect(result.result).toBe("ok"); - const warning = warnSpy.mock.calls[0]?.[0] as string; + const warning = warnSpy.mock.calls + .map((call) => call[0] as string) + .find((value) => value.includes('Model "openai/gpt-6spoof" not found')); expect(warning).toContain('Model "openai/gpt-6spoof" not found'); expect(warning).not.toContain("\u001B"); expect(warning).not.toContain("\n"); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index b9ff9d668ff3a..373e10c936fb5 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -19,6 +19,8 @@ import { isFailoverError, isTimeoutError, } from "./failover-error.js"; +import { logModelFallbackDecision } from "./model-fallback-observation.js"; +import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js"; import { buildConfiguredAllowlistKeys, buildModelAliasIndex, @@ -32,11 +34,6 @@ import { isLikelyContextOverflowError } from "./pi-embedded-helpers.js"; const log = createSubsystemLogger("model-fallback"); -type ModelCandidate = { - provider: string; - model: string; -}; - export type ModelFallbackRunOptions = { allowTransientCooldownProbe?: boolean; }; @@ -47,15 +44,6 @@ type ModelFallbackRunFn = ( options?: ModelFallbackRunOptions, ) => Promise; -type FallbackAttempt = { - provider: string; - model: string; - error: string; - reason?: FailoverReason; - status?: number; - code?: string; -}; - /** * Fallback abort check. Only treats explicit AbortError names as user aborts. * Message-based checks (e.g., "aborted") can mask timeouts and skip fallback. @@ -515,6 +503,7 @@ export async function runWithModelFallback(params: { cfg: OpenClawConfig | undefined; provider: string; model: string; + runId?: string; agentDir?: string; /** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */ fallbacksOverride?: string[]; @@ -537,7 +526,11 @@ export async function runWithModelFallback(params: { for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; + const isPrimary = i === 0; + const requestedModel = + params.provider === candidate.provider && params.model === candidate.model; let runOptions: ModelFallbackRunOptions | undefined; + let attemptedDuringCooldown = false; if (authStore) { const profileIds = resolveAuthProfileOrder({ cfg: params.cfg, @@ -548,9 +541,6 @@ export async function runWithModelFallback(params: { if (profileIds.length > 0 && !isAnyProfileAvailable) { // All profiles for this provider are in cooldown. - const isPrimary = i === 0; - const requestedModel = - params.provider === candidate.provider && params.model === candidate.model; const now = Date.now(); const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir); const decision = resolveCooldownDecision({ @@ -571,6 +561,22 @@ export async function runWithModelFallback(params: { error: decision.error, reason: decision.reason, }); + logModelFallbackDecision({ + decision: "skip_candidate", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + reason: decision.reason, + error: decision.error, + nextCandidate: candidates[i + 1], + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + profileCount: profileIds.length, + }); continue; } @@ -584,6 +590,23 @@ export async function runWithModelFallback(params: { ) { runOptions = { allowTransientCooldownProbe: true }; } + attemptedDuringCooldown = true; + logModelFallbackDecision({ + decision: "probe_cooldown_candidate", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + reason: decision.reason, + nextCandidate: candidates[i + 1], + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, + profileCount: profileIds.length, + }); } } @@ -594,6 +617,21 @@ export async function runWithModelFallback(params: { options: runOptions, }); if ("success" in attemptRun) { + if (i > 0 || attempts.length > 0 || attemptedDuringCooldown) { + logModelFallbackDecision({ + decision: "candidate_succeeded", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + previousAttempts: attempts, + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + }); + } const notFoundAttempt = i > 0 ? attempts.find((a) => a.reason === "model_not_found") : undefined; if (notFoundAttempt) { @@ -637,6 +675,23 @@ export async function runWithModelFallback(params: { status: described.status, code: described.code, }); + logModelFallbackDecision({ + decision: "candidate_failed", + runId: params.runId, + requestedProvider: params.provider, + requestedModel: params.model, + candidate, + attempt: i + 1, + total: candidates.length, + reason: described.reason, + status: described.status, + code: described.code, + error: described.message, + nextCandidate: candidates[i + 1], + isPrimary, + requestedModelMatched: requestedModel, + fallbackConfigured: hasFallbackCandidates, + }); await params.onError?.({ provider: candidate.provider, model: candidate.model, diff --git a/src/agents/model-fallback.types.ts b/src/agents/model-fallback.types.ts new file mode 100644 index 0000000000000..92b5f974788b5 --- /dev/null +++ b/src/agents/model-fallback.types.ts @@ -0,0 +1,15 @@ +import type { FailoverReason } from "./pi-embedded-helpers.js"; + +export type ModelCandidate = { + provider: string; + model: string; +}; + +export type FallbackAttempt = { + provider: string; + model: string; + error: string; + reason?: FailoverReason; + status?: number; + code?: string; +}; diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 432ae17daa19b..2d658aada32a5 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -2,8 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import type { AuthProfileFailureReason } from "./auth-profiles.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; @@ -51,6 +53,7 @@ vi.mock("./models-config.js", async (importOriginal) => { }); let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent; +let unregisterLogTransport: (() => void) | undefined; beforeAll(async () => { ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js")); @@ -64,6 +67,13 @@ beforeEach(() => { sleepWithAbortMock.mockClear(); }); +afterEach(() => { + unregisterLogTransport?.(); + unregisterLogTransport = undefined; + setLoggerOverride(null); + resetLogger(); +}); + const baseUsage = { input: 0, output: 0, @@ -720,6 +730,61 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(sleepWithAbortMock).toHaveBeenCalledWith(321, undefined); }); + it("logs structured failover decision metadata for overloaded assistant rotation", async () => { + const records: Array> = []; + setLoggerOverride({ + level: "trace", + consoleLevel: "silent", + file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`), + }); + unregisterLogTransport = registerLogTransport((record) => { + records.push(record); + }); + + await runAutoPinnedRotationCase({ + errorMessage: + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_overload"}', + sessionKey: "agent:test:overloaded-logging", + runId: "run:overloaded-logging", + }); + + const decisionRecord = records.find( + (record) => + record["2"] === "embedded run failover decision" && + record["1"] && + typeof record["1"] === "object" && + (record["1"] as Record).decision === "rotate_profile", + ); + + expect(decisionRecord).toBeDefined(); + const safeProfileId = redactIdentifier("openai:p1", { len: 12 }); + expect((decisionRecord as Record)["1"]).toMatchObject({ + event: "embedded_run_failover_decision", + runId: "run:overloaded-logging", + decision: "rotate_profile", + failoverReason: "overloaded", + profileId: safeProfileId, + providerErrorType: "overloaded_error", + rawErrorPreview: expect.stringContaining('"request_id":"sha256:'), + }); + + const stateRecord = records.find( + (record) => + record["2"] === "auth profile failure state updated" && + record["1"] && + typeof record["1"] === "object" && + (record["1"] as Record).profileId === safeProfileId, + ); + + expect(stateRecord).toBeDefined(); + expect((stateRecord as Record)["1"]).toMatchObject({ + event: "auth_profile_failure_state_updated", + runId: "run:overloaded-logging", + profileId: safeProfileId, + reason: "overloaded", + }); + }); + it("rotates for overloaded prompt failures across auto-pinned profiles", async () => { const { usageStats } = await runAutoPinnedPromptErrorRotationCase({ errorMessage: '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 68677a009bd63..381c76ada1846 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -763,6 +763,7 @@ export async function runEmbeddedPiAgent( reason, cfg: params.config, agentDir, + runId: params.runId, }); }; const resolveAuthProfileFailureReason = ( diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 6748e3cbe68c0..a3b31c4ccc3e6 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -199,6 +199,7 @@ export async function runAgentTurnWithFallback(params: { const onToolResult = params.opts?.onToolResult; const fallbackResult = await runWithModelFallback({ ...resolveModelFallbackOptions(params.followupRun.run), + runId, run: (provider, model, runOptions) => { // Notify that model selection is complete (including after fallback). // This allows responsePrefix template interpolation with the actual model. diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 374d37d52f7bf..643611d35a21e 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -474,6 +474,7 @@ export async function runMemoryFlushIfNeeded(params: { try { await runWithModelFallback({ ...resolveModelFallbackOptions(params.followupRun.run), + runId: flushRunId, run: async (provider, model, runOptions) => { const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts({ run: params.followupRun.run, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 91e7813810201..8c7eccb5f023f 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -159,6 +159,7 @@ export function createFollowupRunner(params: { cfg: queued.run.config, provider: queued.run.provider, model: queued.run.model, + runId, agentDir: queued.run.agentDir, fallbacksOverride: resolveRunModelFallbacksOverride({ cfg: queued.run.config, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 24e62cc899841..74a5078d03b14 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1103,6 +1103,7 @@ async function agentCommandInternal( cfg, provider, model, + runId, agentDir, fallbacksOverride: effectiveFallbacksOverride, run: (providerOverride, modelOverride, runOptions) => { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 5b665b6bf8fed..0666b752e5cab 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -553,6 +553,7 @@ export async function runCronIsolatedAgentTurn(params: { cfg: cfgWithAgentDefaults, provider, model, + runId: cronSession.sessionEntry.sessionId, agentDir, fallbacksOverride: payloadFallbacks ?? resolveAgentModelFallbacksOverride(params.cfg, agentId), From 56f787e3c0ac4a42d6d644e3aff3c313377487b6 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:22:09 +0100 Subject: [PATCH 292/311] build(protocol): regenerate Swift models after pending node work schemas (#41477) Merged via squash. Prepared head SHA: cae0aaf1c2f23deb65ad797b482d6c212236b18f Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 96 +++++++++++++++++++ .../OpenClawProtocol/GatewayModels.swift | 96 +++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1724d98c8ad0..85871923b8df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf. - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. +- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. ## 2026.3.8 diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index a6223d95bee3d..cf69609e67351 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable { } } +public struct NodePendingDrainParams: Codable, Sendable { + public let maxitems: Int? + + public init( + maxitems: Int?) + { + self.maxitems = maxitems + } + + private enum CodingKeys: String, CodingKey { + case maxitems = "maxItems" + } +} + +public struct NodePendingDrainResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let items: [[String: AnyCodable]] + public let hasmore: Bool + + public init( + nodeid: String, + revision: Int, + items: [[String: AnyCodable]], + hasmore: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.items = items + self.hasmore = hasmore + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case items + case hasmore = "hasMore" + } +} + +public struct NodePendingEnqueueParams: Codable, Sendable { + public let nodeid: String + public let type: String + public let priority: String? + public let expiresinms: Int? + public let wake: Bool? + + public init( + nodeid: String, + type: String, + priority: String?, + expiresinms: Int?, + wake: Bool?) + { + self.nodeid = nodeid + self.type = type + self.priority = priority + self.expiresinms = expiresinms + self.wake = wake + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case type + case priority + case expiresinms = "expiresInMs" + case wake + } +} + +public struct NodePendingEnqueueResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let queued: [String: AnyCodable] + public let waketriggered: Bool + + public init( + nodeid: String, + revision: Int, + queued: [String: AnyCodable], + waketriggered: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.queued = queued + self.waketriggered = waketriggered + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case queued + case waketriggered = "wakeTriggered" + } +} + public struct NodeInvokeRequestEvent: Codable, Sendable { public let id: String public let nodeid: String diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a6223d95bee3d..cf69609e67351 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -950,6 +950,102 @@ public struct NodeEventParams: Codable, Sendable { } } +public struct NodePendingDrainParams: Codable, Sendable { + public let maxitems: Int? + + public init( + maxitems: Int?) + { + self.maxitems = maxitems + } + + private enum CodingKeys: String, CodingKey { + case maxitems = "maxItems" + } +} + +public struct NodePendingDrainResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let items: [[String: AnyCodable]] + public let hasmore: Bool + + public init( + nodeid: String, + revision: Int, + items: [[String: AnyCodable]], + hasmore: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.items = items + self.hasmore = hasmore + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case items + case hasmore = "hasMore" + } +} + +public struct NodePendingEnqueueParams: Codable, Sendable { + public let nodeid: String + public let type: String + public let priority: String? + public let expiresinms: Int? + public let wake: Bool? + + public init( + nodeid: String, + type: String, + priority: String?, + expiresinms: Int?, + wake: Bool?) + { + self.nodeid = nodeid + self.type = type + self.priority = priority + self.expiresinms = expiresinms + self.wake = wake + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case type + case priority + case expiresinms = "expiresInMs" + case wake + } +} + +public struct NodePendingEnqueueResult: Codable, Sendable { + public let nodeid: String + public let revision: Int + public let queued: [String: AnyCodable] + public let waketriggered: Bool + + public init( + nodeid: String, + revision: Int, + queued: [String: AnyCodable], + waketriggered: Bool) + { + self.nodeid = nodeid + self.revision = revision + self.queued = queued + self.waketriggered = waketriggered + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case revision + case queued + case waketriggered = "wakeTriggered" + } +} + public struct NodeInvokeRequestEvent: Codable, Sendable { public let id: String public let nodeid: String From 64746c150c4d721fe30dc301073ea5a1ba83f4de Mon Sep 17 00:00:00 2001 From: Hermione Date: Mon, 9 Mar 2026 22:30:24 +0000 Subject: [PATCH 293/311] fix(discord): apply effective maxLinesPerMessage in live replies (#40133) Merged via squash. Prepared head SHA: 031d0325347abd11892fbd5f44328f6b3c043902 Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/discord/accounts.test.ts | 61 ++++++++++++++++++- src/discord/accounts.ts | 14 +++++ src/discord/monitor/agent-components.ts | 7 ++- .../monitor/message-handler.process.test.ts | 32 ++++++++++ .../monitor/message-handler.process.ts | 10 ++- .../native-command.commands-allowfrom.test.ts | 45 +++++++++++++- src/discord/monitor/native-command.ts | 5 +- src/discord/monitor/reply-delivery.test.ts | 23 +++++++ src/discord/monitor/reply-delivery.ts | 19 +++++- 10 files changed, 207 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85871923b8df4..534922abe57da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. +- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. ## 2026.3.8 diff --git a/src/discord/accounts.test.ts b/src/discord/accounts.test.ts index 6fd11965a075b..1f6d70b1ea0c1 100644 --- a/src/discord/accounts.test.ts +++ b/src/discord/accounts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveDiscordAccount } from "./accounts.js"; +import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js"; describe("resolveDiscordAccount allowFrom precedence", () => { it("prefers accounts.default.allowFrom over top-level for default account", () => { @@ -56,3 +56,62 @@ describe("resolveDiscordAccount allowFrom precedence", () => { expect(resolved.config.allowFrom).toBeUndefined(); }); }); + +describe("resolveDiscordMaxLinesPerMessage", () => { + it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + default: { token: "token-default" }, + }, + }, + }, + }, + discordConfig: {}, + accountId: "default", + }); + + expect(resolved).toBe(120); + }); + + it("prefers explicit runtime discord maxLinesPerMessage over merged config", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + default: { token: "token-default", maxLinesPerMessage: 80 }, + }, + }, + }, + }, + discordConfig: { maxLinesPerMessage: 55 }, + accountId: "default", + }); + + expect(resolved).toBe(55); + }); + + it("uses per-account discord maxLinesPerMessage over the root value when runtime config omits it", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + work: { token: "token-work", maxLinesPerMessage: 80 }, + }, + }, + }, + }, + discordConfig: {}, + accountId: "work", + }); + + expect(resolved).toBe(80); + }); +}); diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 75eeff40b3e29..b4e71c78343df 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -68,6 +68,20 @@ export function resolveDiscordAccount(params: { }; } +export function resolveDiscordMaxLinesPerMessage(params: { + cfg: OpenClawConfig; + discordConfig?: DiscordAccountConfig | null; + accountId?: string | null; +}): number | undefined { + if (typeof params.discordConfig?.maxLinesPerMessage === "number") { + return params.discordConfig.maxLinesPerMessage; + } + return resolveDiscordAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).config.maxLinesPerMessage; +} + export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] { return listDiscordAccountIds(cfg) .map((accountId) => resolveDiscordAccount({ cfg, accountId })) diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index deeb9b35221e8..16b3f564bfe44 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -43,6 +43,7 @@ import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, } from "../../security/dm-policy-shared.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { createDiscordFormModal, @@ -1017,7 +1018,11 @@ async function dispatchDiscordComponentEvent(params: { replyToId, replyToMode, textLimit, - maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ + cfg: ctx.cfg, + discordConfig: ctx.discordConfig, + accountId, + }), tableMode, chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), mediaLocalRoots, diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 9bc9cf774987d..8b059d00f397b 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -502,6 +502,38 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("uses root discord maxLinesPerMessage for preview finalization when runtime config omits it", async () => { + const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: longReply }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { ackReaction: "👀" }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + channels: { + discord: { + maxLinesPerMessage: 120, + }, + }, + }, + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(editMessageDiscord).toHaveBeenCalledWith( + "c1", + "preview-1", + { content: longReply }, + { rest: {} }, + ); + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + it("suppresses reasoning payload delivery to Discord", async () => { mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true }); await processStreamOffDiscordMessage(); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 85bbccd59d39b..c283658ac0979 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -32,6 +32,7 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; import { createDiscordDraftStream } from "../draft-stream.js"; @@ -426,6 +427,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) channel: "discord", accountId, }); + const maxLinesPerMessage = resolveDiscordMaxLinesPerMessage({ + cfg, + discordConfig, + accountId, + }); const chunkMode = resolveChunkMode(cfg, "discord", accountId); const typingCallbacks = createTypingCallbacks({ @@ -484,7 +490,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const formatted = convertMarkdownTables(text, tableMode); const chunks = chunkDiscordTextWithMode(formatted, { maxChars: draftMaxChars, - maxLines: discordConfig?.maxLinesPerMessage, + maxLines: maxLinesPerMessage, chunkMode, }); if (!chunks.length && formatted) { @@ -687,7 +693,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) replyToId, replyToMode, textLimit, - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage, tableMode, chunkMode, sessionKey: ctxPayload.SessionKey, diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/src/discord/monitor/native-command.commands-allowfrom.test.ts index 218df22f0714f..5144eb7426741 100644 --- a/src/discord/monitor/native-command.commands-allowfrom.test.ts +++ b/src/discord/monitor/native-command.commands-allowfrom.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { DiscordAccountConfig } from "../../config/types.discord.js"; import * as pluginCommandsModule from "../../plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { @@ -49,7 +50,7 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } -function createCommand(cfg: OpenClawConfig) { +function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) { const commandSpec: NativeCommandSpec = { name: "status", description: "Status", @@ -58,7 +59,7 @@ function createCommand(cfg: OpenClawConfig) { return createDiscordNativeCommand({ command: commandSpec, cfg, - discordConfig: cfg.channels?.discord ?? {}, + discordConfig: discordConfig ?? cfg.channels?.discord ?? {}, accountId: "default", sessionPrefix: "discord:slash", ephemeralDefault: true, @@ -79,10 +80,11 @@ function createDispatchSpy() { async function runGuildSlashCommand(params?: { userId?: string; mutateConfig?: (cfg: OpenClawConfig) => void; + runtimeDiscordConfig?: DiscordAccountConfig; }) { const cfg = createConfig(); params?.mutateConfig?.(cfg); - const command = createCommand(cfg); + const command = createCommand(cfg, params?.runtimeDiscordConfig); const interaction = createInteraction({ userId: params?.userId }); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); const dispatchSpy = createDispatchSpy(); @@ -164,4 +166,41 @@ describe("Discord native slash commands with commands.allowFrom", () => { expect(dispatchSpy).not.toHaveBeenCalled(); expectUnauthorizedReply(interaction); }); + + it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => { + const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); + const { interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.channels = { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + maxLinesPerMessage: 120, + }, + }; + }, + runtimeDiscordConfig: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "234567890123456789": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }); + + const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock + .calls[0]?.[0] as + | Parameters[0] + | undefined; + await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" }); + + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply })); + expect(interaction.followUp).not.toHaveBeenCalled(); + }); }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 23b5bcd4c9d68..4af7d5ef6d38f 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -56,6 +56,7 @@ import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; import { chunkItems } from "../../utils/chunk-items.js"; import { withTimeout } from "../../utils/with-timeout.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { isDiscordGroupAllowedByPolicy, @@ -1571,7 +1572,7 @@ async function dispatchDiscordCommandInteraction(params: { textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); @@ -1706,7 +1707,7 @@ async function dispatchDiscordCommandInteraction(params: { textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp: preferFollowUp || didReply, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 3274a669cf2ed..3d0357ef43a81 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -256,6 +256,29 @@ describe("deliverDiscordReply", () => { expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789"); }); + it("passes maxLinesPerMessage and chunkMode through the fast path", async () => { + const fakeRest = {} as import("@buape/carbon").RequestClient; + + await deliverDiscordReply({ + replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }], + target: "channel:789", + token: "token", + rest: fakeRest, + runtime, + textLimit: 2000, + maxLinesPerMessage: 120, + chunkMode: "newline", + }); + + expect(sendMessageDiscordMock).not.toHaveBeenCalled(); + expect(sendDiscordTextMock).toHaveBeenCalledTimes(1); + const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0]; + const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? []; + + expect(maxLinesPerMessageArg).toBe(120); + expect(chunkModeArg).toBe("newline"); + }); + it("falls back to sendMessageDiscord when rest is not provided", async () => { await deliverDiscordReply({ replies: [{ text: "single chunk" }], diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index 11fc1733ef11b..d3e7ef9bf617a 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -130,9 +130,11 @@ async function sendDiscordChunkWithFallback(params: { text: string; token: string; accountId?: string; + maxLinesPerMessage?: number; rest?: RequestClient; replyTo?: string; binding?: DiscordThreadBindingLookupRecord; + chunkMode?: ChunkMode; username?: string; avatarUrl?: string; /** Pre-resolved channel ID to bypass redundant resolution per chunk. */ @@ -169,7 +171,18 @@ async function sendDiscordChunkWithFallback(params: { if (params.channelId && params.request && params.rest) { const { channelId, request, rest } = params; await sendWithRetry( - () => sendDiscordText(rest, channelId, text, params.replyTo, request), + () => + sendDiscordText( + rest, + channelId, + text, + params.replyTo, + request, + params.maxLinesPerMessage, + undefined, + undefined, + params.chunkMode, + ), params.retryConfig, ); return; @@ -294,8 +307,10 @@ export async function deliverDiscordReply(params: { token: params.token, rest: params.rest, accountId: params.accountId, + maxLinesPerMessage: params.maxLinesPerMessage, replyTo, binding, + chunkMode: params.chunkMode, username: persona.username, avatarUrl: persona.avatarUrl, channelId, @@ -329,8 +344,10 @@ export async function deliverDiscordReply(params: { token: params.token, rest: params.rest, accountId: params.accountId, + maxLinesPerMessage: params.maxLinesPerMessage, replyTo: resolveReplyTo(), binding, + chunkMode: params.chunkMode, username: persona.username, avatarUrl: persona.avatarUrl, channelId, From de4c3db3e38a14d90d8ce3730e6ef83a1b79881e Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 01:40:15 +0300 Subject: [PATCH 294/311] Logging: harden probe suppression for observations (#41338) Merged via squash. Prepared head SHA: d18356cb8062935090466d4e142ce202381d4ef2 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/logging/subsystem.test.ts | 118 +++++++++++++++++++++++++++++++++- src/logging/subsystem.ts | 47 +++++++++++--- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534922abe57da..40fafa2192016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. +- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. ## 2026.3.8 diff --git a/src/logging/subsystem.test.ts b/src/logging/subsystem.test.ts index e389d78ba8ac5..06f504f47de51 100644 --- a/src/logging/subsystem.test.ts +++ b/src/logging/subsystem.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { setConsoleSubsystemFilter } from "./console.js"; import { resetLogger, setLoggerOverride } from "./logger.js"; +import { loggingState } from "./state.js"; import { createSubsystemLogger } from "./subsystem.js"; afterEach(() => { setConsoleSubsystemFilter(null); setLoggerOverride(null); + loggingState.rawConsole = null; resetLogger(); }); @@ -53,4 +55,118 @@ describe("createSubsystemLogger().isEnabled", () => { expect(log.isEnabled("info", "file")).toBe(true); expect(log.isEnabled("info")).toBe(true); }); + + it("suppresses probe warnings for embedded subsystems based on structured run metadata", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("agent/embedded").child("failover"); + + log.warn("embedded run failover decision", { + runId: "probe-test-run", + consoleMessage: "embedded run failover decision", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not suppress probe errors for embedded subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "error" }); + const error = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error, + }; + const log = createSubsystemLogger("agent/embedded").child("failover"); + + log.error("embedded run failover decision", { + runId: "probe-test-run", + consoleMessage: "embedded run failover decision", + }); + + expect(error).toHaveBeenCalledTimes(1); + }); + + it("suppresses probe warnings for model-fallback child subsystems based on structured run metadata", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.warn("model fallback decision", { + runId: "probe-test-run", + consoleMessage: "model fallback decision", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("does not suppress probe errors for model-fallback child subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "error" }); + const error = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error, + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.error("model fallback decision", { + runId: "probe-test-run", + consoleMessage: "model fallback decision", + }); + + expect(error).toHaveBeenCalledTimes(1); + }); + + it("still emits non-probe warnings for embedded subsystems", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("agent/embedded").child("auth-profiles"); + + log.warn("auth profile failure state updated", { + runId: "run-123", + consoleMessage: "auth profile failure state updated", + }); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("still emits non-probe model-fallback child warnings", () => { + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + const warn = vi.fn(); + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn, + error: vi.fn(), + }; + const log = createSubsystemLogger("model-fallback").child("decision"); + + log.warn("model fallback decision", { + runId: "run-123", + consoleMessage: "model fallback decision", + }); + + expect(warn).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index 18be000e9ba3c..5c6ce58a43d50 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -250,6 +250,38 @@ function writeConsoleLine(level: LogLevel, line: string) { } } +function shouldSuppressProbeConsoleLine(params: { + level: LogLevel; + subsystem: string; + message: string; + meta?: Record; +}): boolean { + if (isVerbose()) { + return false; + } + if (params.level === "error" || params.level === "fatal") { + return false; + } + const isProbeSuppressedSubsystem = + params.subsystem === "agent/embedded" || + params.subsystem.startsWith("agent/embedded/") || + params.subsystem === "model-fallback" || + params.subsystem.startsWith("model-fallback/"); + if (!isProbeSuppressedSubsystem) { + return false; + } + const runLikeId = + typeof params.meta?.runId === "string" + ? params.meta.runId + : typeof params.meta?.sessionId === "string" + ? params.meta.sessionId + : undefined; + if (runLikeId?.startsWith("probe-")) { + return true; + } + return /(sessionId|runId)=probe-/.test(params.message); +} + function logToFile( fileLogger: TsLogger, level: LogLevel, @@ -309,9 +341,12 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { } const consoleMessage = consoleMessageOverride ?? message; if ( - !isVerbose() && - subsystem === "agent/embedded" && - /(sessionId|runId)=probe-/.test(consoleMessage) + shouldSuppressProbeConsoleLine({ + level, + subsystem, + message: consoleMessage, + meta: fileMeta, + }) ) { return; } @@ -355,11 +390,7 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { logToFile(getFileLogger(), "info", message, { raw: true }); } if (isConsoleEnabled("info")) { - if ( - !isVerbose() && - subsystem === "agent/embedded" && - /(sessionId|runId)=probe-/.test(message) - ) { + if (shouldSuppressProbeConsoleLine({ level: "info", subsystem, message })) { return; } writeConsoleLine("info", message); From c9a6c542ef7ae9350fd79e20a7e6642b5ce4d604 Mon Sep 17 00:00:00 2001 From: alan blount Date: Mon, 9 Mar 2026 18:55:10 -0400 Subject: [PATCH 295/311] Add HTTP 499 to transient error codes for model fallback (#41468) Merged via squash. Prepared head SHA: 0053bae14038e6df9264df364d1c9aa83d5b698e Co-authored-by: zeroasterisk <23422+zeroasterisk@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/failover-error.test.ts | 7 +++++++ ...-embedded-helpers.isbillingerrormessage.test.ts | 14 ++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 8 +++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fafa2192016..48ec8f4455296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. +- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. ## 2026.3.8 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index a99cfb5c4b261..db01c03d8c474 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -67,6 +67,7 @@ describe("failover-error", () => { expect(resolveFailoverReasonFromError({ statusCode: "429" })).toBe("rate_limit"); expect(resolveFailoverReasonFromError({ status: 403 })).toBe("auth"); expect(resolveFailoverReasonFromError({ status: 408 })).toBe("timeout"); + expect(resolveFailoverReasonFromError({ status: 499 })).toBe("timeout"); expect(resolveFailoverReasonFromError({ status: 400 })).toBe("format"); // Keep the status-only path behavior-preserving and conservative. expect(resolveFailoverReasonFromError({ status: 500 })).toBeNull(); @@ -93,6 +94,12 @@ describe("failover-error", () => { message: ANTHROPIC_OVERLOADED_PAYLOAD, }), ).toBe("overloaded"); + expect( + resolveFailoverReasonFromError({ + status: 499, + message: ANTHROPIC_OVERLOADED_PAYLOAD, + }), + ).toBe("overloaded"); expect( resolveFailoverReasonFromError({ status: 429, diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 86fd90e71614c..f60a127a0abe6 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -443,6 +443,7 @@ describe("isLikelyContextOverflowError", () => { describe("isTransientHttpError", () => { it("returns true for retryable 5xx status codes", () => { + expect(isTransientHttpError("499 Client Closed Request")).toBe(true); expect(isTransientHttpError("500 Internal Server Error")).toBe(true); expect(isTransientHttpError("502 Bad Gateway")).toBe(true); expect(isTransientHttpError("503 Service Unavailable")).toBe(true); @@ -457,6 +458,19 @@ describe("isTransientHttpError", () => { }); }); +describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 499 as transient for structured errors", () => { + expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); + expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); + expect( + classifyFailoverReasonFromHttpStatus( + 499, + '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', + ), + ).toBe("overloaded"); + }); +}); + describe("isFailoverErrorMessage", () => { it("matches auth/rate/billing/timeout", () => { const samples = [ diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 4cf347150bf1b..9ab52c043554c 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -189,7 +189,7 @@ 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*(?: Date: Tue, 10 Mar 2026 00:07:26 +0100 Subject: [PATCH 296/311] fix(plugins): expose model auth API to context-engine plugins (#41090) Merged via squash. Prepared head SHA: ee96e96bb984cc3e1e152d17199357a8f6db312d Co-authored-by: xinhuagu <562450+xinhuagu@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + extensions/test-utils/plugin-runtime-mock.ts | 5 +++++ src/plugin-sdk/index.ts | 6 ++++++ src/plugins/runtime/index.test.ts | 17 +++++++++++++++ src/plugins/runtime/index.ts | 22 ++++++++++++++++++++ src/plugins/runtime/types-core.ts | 12 +++++++++++ 6 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ec8f4455296..9f705ed77a316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. +- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. ## 2026.3.8 diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 8c599599a3121..81e3fdedeec5f 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -253,6 +253,11 @@ export function createPluginRuntimeMock(overrides: DeepPartial = state: { resolveStateDir: vi.fn(() => "/tmp/openclaw"), }, + modelAuth: { + getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"], + resolveApiKeyForProvider: + vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"], + }, subagent: { run: vi.fn(), waitForRun: vi.fn(), diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 3e1ba0f03ab8e..35709dc4fec0a 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -801,5 +801,11 @@ export type { export { registerContextEngine } from "../context-engine/registry.js"; export type { ContextEngineFactory } from "../context-engine/registry.js"; +// Model authentication types for plugins. +// Plugins should use runtime.modelAuth (which strips unsafe overrides like +// agentDir/store) rather than importing raw helpers directly. +export { requireApiKey } from "../agents/model-auth.js"; +export type { ResolvedProviderAuth } from "../agents/model-auth.js"; + // Security utilities export { redactSensitiveText } from "../logging/redact.js"; diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 77b3de6606249..5ec2df281999e 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -53,4 +53,21 @@ describe("plugin runtime command execution", () => { const runtime = createPluginRuntime(); expect(runtime.system.requestHeartbeatNow).toBe(requestHeartbeatNow); }); + + it("exposes runtime.modelAuth with getApiKeyForModel and resolveApiKeyForProvider", () => { + const runtime = createPluginRuntime(); + expect(runtime.modelAuth).toBeDefined(); + expect(typeof runtime.modelAuth.getApiKeyForModel).toBe("function"); + expect(typeof runtime.modelAuth.resolveApiKeyForProvider).toBe("function"); + }); + + it("modelAuth wrappers strip agentDir and store to prevent credential steering", async () => { + // The wrappers should not forward agentDir or store from plugin callers. + // We verify this by checking the wrapper functions exist and are not the + // raw implementations (they are wrapped, not direct references). + const { getApiKeyForModel: rawGetApiKey } = await import("../../agents/model-auth.js"); + const runtime = createPluginRuntime(); + // Wrappers should NOT be the same reference as the raw functions + expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 68b672db1b496..12d33168cd34f 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,4 +1,8 @@ import { createRequire } from "node:module"; +import { + getApiKeyForModel as getApiKeyForModelRaw, + resolveApiKeyForProvider as resolveApiKeyForProviderRaw, +} from "../../agents/model-auth.js"; import { resolveStateDir } from "../../config/paths.js"; import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; import { textToSpeechTelephony } from "../../tts/tts.js"; @@ -59,6 +63,24 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): events: createRuntimeEvents(), logging: createRuntimeLogging(), state: { resolveStateDir }, + modelAuth: { + // Wrap model-auth helpers so plugins cannot steer credential lookups: + // - agentDir / store: stripped (prevents reading other agents' stores) + // - profileId / preferredProfile: stripped (prevents cross-provider + // credential access via profile steering) + // Plugins only specify provider/model; the core auth pipeline picks + // the appropriate credential automatically. + getApiKeyForModel: (params) => + getApiKeyForModelRaw({ + model: params.model, + cfg: params.cfg, + }), + resolveApiKeyForProvider: (params) => + resolveApiKeyForProviderRaw({ + provider: params.provider, + cfg: params.cfg, + }), + }, } satisfies PluginRuntime; return runtime; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index 524b3a5f6a2b0..bfbb747c9c442 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -52,4 +52,16 @@ export type PluginRuntimeCore = { state: { resolveStateDir: typeof import("../../config/paths.js").resolveStateDir; }; + modelAuth: { + /** Resolve auth for a model. Only provider/model and optional cfg are used. */ + getApiKeyForModel: (params: { + model: import("@mariozechner/pi-ai").Model; + cfg?: import("../../config/config.js").OpenClawConfig; + }) => Promise; + /** Resolve auth for a provider by name. Only provider and optional cfg are used. */ + resolveApiKeyForProvider: (params: { + provider: string; + cfg?: import("../../config/config.js").OpenClawConfig; + }) => Promise; + }; }; From b48291e01eca26a5b04ea1d6219c13b4437c3ead Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 9 Mar 2026 16:14:08 -0700 Subject: [PATCH 297/311] Exec: mark child command env with OPENCLAW_CLI (#41411) --- src/agents/sandbox-create-args.test.ts | 10 +++++++++- src/agents/sandbox/docker.ts | 3 ++- src/entry.ts | 2 ++ src/infra/host-env-security.test.ts | 5 +++++ src/infra/host-env-security.ts | 5 +++-- src/infra/openclaw-exec-env.ts | 16 ++++++++++++++++ src/process/exec.test.ts | 2 ++ src/process/exec.ts | 3 ++- 8 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/infra/openclaw-exec-env.ts diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index 9bc005471439b..0d9621ad9e117 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { buildSandboxCreateArgs } from "./sandbox/docker.js"; import type { SandboxDockerConfig } from "./sandbox/types.js"; @@ -113,7 +114,14 @@ describe("buildSandboxCreateArgs", () => { "1.5", ]), ); - expect(args).toEqual(expect.arrayContaining(["--env", "LANG=C.UTF-8"])); + expect(args).toEqual( + expect.arrayContaining([ + "--env", + "LANG=C.UTF-8", + "--env", + `OPENCLAW_CLI=${OPENCLAW_CLI_ENV_VALUE}`, + ]), + ); const ulimitValues: string[] = []; for (let i = 0; i < args.length; i += 1) { diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 2bd9dad12b52b..68c95e343ea11 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -162,6 +162,7 @@ export function execDockerRaw( } import { formatCliCommand } from "../../cli/command-format.js"; +import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js"; import { defaultRuntime } from "../../runtime.js"; import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE } from "./constants.js"; @@ -365,7 +366,7 @@ export function buildSandboxCreateArgs(params: { if (params.cfg.user) { args.push("--user", params.cfg.user); } - const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}); + const envSanitization = sanitizeEnvVars(markOpenClawExecEnv(params.cfg.env ?? {})); if (envSanitization.blocked.length > 0) { log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`); } diff --git a/src/entry.ts b/src/entry.ts index 50b08029d05bd..14a839f38b980 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -9,6 +9,7 @@ import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; +import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js"; import { installProcessWarningFilter } from "./infra/warning-filter.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; @@ -41,6 +42,7 @@ if ( // Imported as a dependency — skip all entry-point side effects. } else { process.title = "openclaw"; + ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); normalizeEnv(); if (!isTruthyEnvValue(process.env.NODE_DISABLE_COMPILE_CACHE)) { diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index 116006dbbcfac..4e7bcdb9ed92b 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -10,6 +10,7 @@ import { sanitizeHostExecEnv, sanitizeSystemRunEnvOverrides, } from "./host-env-security.js"; +import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js"; describe("isDangerousHostEnvVarName", () => { it("matches dangerous keys and prefixes case-insensitively", () => { @@ -40,6 +41,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env).toEqual({ + OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE, PATH: "/usr/bin:/bin", OK: "1", }); @@ -68,6 +70,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.BASH_ENV).toBeUndefined(); expect(env.GIT_SSH_COMMAND).toBeUndefined(); expect(env.EDITOR).toBeUndefined(); @@ -91,6 +94,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env.OK).toBe("1"); expect(env.SHELLOPTS).toBeUndefined(); expect(env.PS4).toBeUndefined(); @@ -109,6 +113,7 @@ describe("sanitizeHostExecEnv", () => { }); expect(env.GOOD_KEY).toBe("ok"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); expect(env[" BAD KEY"]).toBeUndefined(); expect(env["NOT-PORTABLE"]).toBeUndefined(); }); diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 56b30bd0818f1..8c5d0989fdd09 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -1,4 +1,5 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with { type: "json" }; +import { markOpenClawExecEnv } from "./openclaw-exec-env.js"; const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -101,7 +102,7 @@ export function sanitizeHostExecEnv(params?: { } if (!overrides) { - return merged; + return markOpenClawExecEnv(merged); } for (const [rawKey, value] of Object.entries(overrides)) { @@ -124,7 +125,7 @@ export function sanitizeHostExecEnv(params?: { merged[key] = value; } - return merged; + return markOpenClawExecEnv(merged); } export function sanitizeSystemRunEnvOverrides(params?: { diff --git a/src/infra/openclaw-exec-env.ts b/src/infra/openclaw-exec-env.ts new file mode 100644 index 0000000000000..b4e8a8765841c --- /dev/null +++ b/src/infra/openclaw-exec-env.ts @@ -0,0 +1,16 @@ +export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI"; +export const OPENCLAW_CLI_ENV_VALUE = "1"; + +export function markOpenClawExecEnv>(env: T): T { + return { + ...env, + [OPENCLAW_CLI_ENV_VAR]: OPENCLAW_CLI_ENV_VALUE, + }; +} + +export function ensureOpenClawExecMarkerOnProcess( + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + env[OPENCLAW_CLI_ENV_VAR] = OPENCLAW_CLI_ENV_VALUE; + return env; +} diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 19937d6cb3263..88d9cfdd71e37 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import process from "node:process"; import { describe, expect, it, vi } from "vitest"; +import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js"; import { attachChildProcessBridge } from "./child-process-bridge.js"; import { resolveCommandEnv, runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; @@ -31,6 +32,7 @@ describe("runCommandWithTimeout", () => { expect(resolved.OPENCLAW_BASE_ENV).toBe("base"); expect(resolved.OPENCLAW_TEST_ENV).toBe("ok"); expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined(); + expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); }); it("suppresses npm fund prompts for npm argv", async () => { diff --git a/src/process/exec.ts b/src/process/exec.ts index ddc572092d8b9..3464a0838949e 100644 --- a/src/process/exec.ts +++ b/src/process/exec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; import { danger, shouldLogVerbose } from "../globals.js"; +import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js"; import { logDebug, logError } from "../logger.js"; import { resolveCommandStdio } from "./spawn-utils.js"; @@ -213,7 +214,7 @@ export function resolveCommandEnv(params: { resolvedEnv.npm_config_fund = "false"; } } - return resolvedEnv; + return markOpenClawExecEnv(resolvedEnv); } export async function runCommandWithTimeout( From c0cba7fb72ea7490b89ab194041287bea4017f3e Mon Sep 17 00:00:00 2001 From: Julia Barth <72460857+Julbarth@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:34:46 -0700 Subject: [PATCH 298/311] Fix one-shot exit hangs by tearing down cached memory managers (#40389) Merged via squash. Prepared head SHA: 0e600e89cf10f5086ab9d93f445587373a54dcec Co-authored-by: Julbarth <72460857+Julbarth@users.noreply.github.com> Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com> Reviewed-by: @frankekn --- CHANGELOG.md | 1 + src/cli/run-main.exit.test.ts | 6 ++ src/cli/run-main.ts | 109 ++++++++++++--------- src/memory/index.ts | 6 +- src/memory/manager-runtime.ts | 2 +- src/memory/manager.get-concurrency.test.ts | 37 +++++++ src/memory/manager.ts | 16 +++ src/memory/search-manager.test.ts | 107 +++++++++++++------- src/memory/search-manager.ts | 16 +++ 9 files changed, 213 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f705ed77a316..ce8a07061ecd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf. - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. +- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. ## 2026.3.8 diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 86d74f09640d6..3e56c1ce7947c 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -6,6 +6,7 @@ const loadDotEnvMock = vi.hoisted(() => vi.fn()); const normalizeEnvMock = vi.hoisted(() => vi.fn()); const ensurePathMock = vi.hoisted(() => vi.fn()); const assertRuntimeMock = vi.hoisted(() => vi.fn()); +const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("./route.js", () => ({ tryRouteCli: tryRouteCliMock, @@ -27,6 +28,10 @@ vi.mock("../infra/runtime-guard.js", () => ({ assertSupportedRuntime: assertRuntimeMock, })); +vi.mock("../memory/search-manager.js", () => ({ + closeAllMemorySearchManagers: closeAllMemorySearchManagersMock, +})); + const { runCli } = await import("./run-main.js"); describe("runCli exit behavior", () => { @@ -43,6 +48,7 @@ describe("runCli exit behavior", () => { await runCli(["node", "openclaw", "status"]); expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]); + expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1); expect(exitSpy).not.toHaveBeenCalled(); exitSpy.mockRestore(); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index e80ce97b845c9..c0673ddf2af5f 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -13,6 +13,15 @@ import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; import { tryRouteCli } from "./route.js"; import { normalizeWindowsArgv } from "./windows-argv.js"; +async function closeCliMemoryManagers(): Promise { + try { + const { closeAllMemorySearchManagers } = await import("../memory/search-manager.js"); + await closeAllMemorySearchManagers(); + } catch { + // Best-effort teardown for short-lived CLI processes. + } +} + export function rewriteUpdateFlagArgv(argv: string[]): string[] { const index = argv.indexOf("--update"); if (index === -1) { @@ -82,59 +91,63 @@ export async function runCli(argv: string[] = process.argv) { // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime(); - if (await tryRouteCli(normalizedArgv)) { - return; - } + try { + if (await tryRouteCli(normalizedArgv)) { + return; + } - // Capture all console output into structured logs while keeping stdout/stderr behavior. - enableConsoleCapture(); - - const { buildProgram } = await import("./program.js"); - const program = buildProgram(); - - // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. - // These log the error and exit gracefully instead of crashing without trace. - installUnhandledRejectionHandler(); - - process.on("uncaughtException", (error) => { - console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); - process.exit(1); - }); - - const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); - // Register the primary command (builtin or subcli) so help and command parsing - // are correct even with lazy command registration. - const primary = getPrimaryCommand(parseArgv); - if (primary) { - const { getProgramContext } = await import("./program/program-context.js"); - const ctx = getProgramContext(program); - if (ctx) { - const { registerCoreCliByName } = await import("./program/command-registry.js"); - await registerCoreCliByName(program, ctx, primary, parseArgv); + // Capture all console output into structured logs while keeping stdout/stderr behavior. + enableConsoleCapture(); + + const { buildProgram } = await import("./program.js"); + const program = buildProgram(); + + // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. + // These log the error and exit gracefully instead of crashing without trace. + installUnhandledRejectionHandler(); + + process.on("uncaughtException", (error) => { + console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); + process.exit(1); + }); + + const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); + // Register the primary command (builtin or subcli) so help and command parsing + // are correct even with lazy command registration. + const primary = getPrimaryCommand(parseArgv); + if (primary) { + const { getProgramContext } = await import("./program/program-context.js"); + const ctx = getProgramContext(program); + if (ctx) { + const { registerCoreCliByName } = await import("./program/command-registry.js"); + await registerCoreCliByName(program, ctx, primary, parseArgv); + } + const { registerSubCliByName } = await import("./program/register.subclis.js"); + await registerSubCliByName(program, primary); } - const { registerSubCliByName } = await import("./program/register.subclis.js"); - await registerSubCliByName(program, primary); - } - const hasBuiltinPrimary = - primary !== null && program.commands.some((command) => command.name() === primary); - const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ - argv: parseArgv, - primary, - hasBuiltinPrimary, - }); - if (!shouldSkipPluginRegistration) { - // Register plugin CLI commands before parsing - const { registerPluginCliCommands } = await import("../plugins/cli.js"); - const { loadValidatedConfigForPluginRegistration } = - await import("./program/register.subclis.js"); - const config = await loadValidatedConfigForPluginRegistration(); - if (config) { - registerPluginCliCommands(program, config); + const hasBuiltinPrimary = + primary !== null && program.commands.some((command) => command.name() === primary); + const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({ + argv: parseArgv, + primary, + hasBuiltinPrimary, + }); + if (!shouldSkipPluginRegistration) { + // Register plugin CLI commands before parsing + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + const { loadValidatedConfigForPluginRegistration } = + await import("./program/register.subclis.js"); + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + registerPluginCliCommands(program, config); + } } - } - await program.parseAsync(parseArgv); + await program.parseAsync(parseArgv); + } finally { + await closeCliMemoryManagers(); + } } export function isCliMainModule(): boolean { diff --git a/src/memory/index.ts b/src/memory/index.ts index 4d2df05a3997e..86ca52e1d2710 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -4,4 +4,8 @@ export type { MemorySearchManager, MemorySearchResult, } from "./types.js"; -export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js"; +export { + closeAllMemorySearchManagers, + getMemorySearchManager, + type MemorySearchManagerResult, +} from "./search-manager.js"; diff --git a/src/memory/manager-runtime.ts b/src/memory/manager-runtime.ts index b46b3708a6eef..3e910b5676aa7 100644 --- a/src/memory/manager-runtime.ts +++ b/src/memory/manager-runtime.ts @@ -1 +1 @@ -export { MemoryIndexManager } from "./manager.js"; +export { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js"; diff --git a/src/memory/manager.get-concurrency.test.ts b/src/memory/manager.get-concurrency.test.ts index e7d040217a87d..67b10768fc320 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/src/memory/manager.get-concurrency.test.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; +import { + closeAllMemoryIndexManagers, + MemoryIndexManager as RawMemoryIndexManager, +} from "./manager.js"; import "./test-runtime-mocks.js"; const hoisted = vi.hoisted(() => ({ @@ -78,4 +82,37 @@ describe("memory manager cache hydration", () => { await managers[0].close(); }); + + it("drains in-flight manager creation during global teardown", async () => { + const indexPath = path.join(workspaceDir, "index.sqlite"); + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath, vector: { enabled: false } }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + hoisted.providerDelayMs = 100; + + const pendingResult = RawMemoryIndexManager.get({ cfg, agentId: "main" }); + await closeAllMemoryIndexManagers(); + const firstManager = await pendingResult; + + const secondManager = await RawMemoryIndexManager.get({ cfg, agentId: "main" }); + + expect(firstManager).toBeTruthy(); + expect(secondManager).toBeTruthy(); + expect(Object.is(secondManager, firstManager)).toBe(false); + expect(hoisted.providerCreateCalls).toBe(2); + + await secondManager?.close?.(); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 1d2fb49e88bc2..9b1ff74e54c13 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -42,6 +42,22 @@ const log = createSubsystemLogger("memory"); const INDEX_CACHE = new Map(); const INDEX_CACHE_PENDING = new Map>(); +export async function closeAllMemoryIndexManagers(): Promise { + const pending = Array.from(INDEX_CACHE_PENDING.values()); + if (pending.length > 0) { + await Promise.allSettled(pending); + } + const managers = Array.from(INDEX_CACHE.values()); + INDEX_CACHE.clear(); + for (const manager of managers) { + try { + await manager.close(); + } catch (err) { + log.warn(`failed to close memory index manager: ${String(err)}`); + } + } +} + export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager { private readonly cacheKey: string; protected readonly cfg: OpenClawConfig; diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index d853f5af1faaf..1f705aeddcfec 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -29,53 +29,53 @@ function createManagerStatus(params: { }; } -const qmdManagerStatus = createManagerStatus({ - backend: "qmd", - provider: "qmd", - model: "qmd", - requestedProvider: "qmd", - withMemorySourceCounts: true, -}); - -const fallbackManagerStatus = createManagerStatus({ - backend: "builtin", - provider: "openai", - model: "text-embedding-3-small", - requestedProvider: "openai", -}); - -const mockPrimary = { +const mockPrimary = vi.hoisted(() => ({ search: vi.fn(async () => []), readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => qmdManagerStatus), + status: vi.fn(() => + createManagerStatus({ + backend: "qmd", + provider: "qmd", + model: "qmd", + requestedProvider: "qmd", + withMemorySourceCounts: true, + }), + ), sync: vi.fn(async () => {}), probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), -}; - -const fallbackSearch = vi.fn(async () => [ - { - path: "MEMORY.md", - startLine: 1, - endLine: 1, - score: 1, - snippet: "fallback", - source: "memory" as const, - }, -]); +})); -const fallbackManager = { - search: fallbackSearch, +const fallbackManager = vi.hoisted(() => ({ + search: vi.fn(async () => [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 1, + score: 1, + snippet: "fallback", + source: "memory" as const, + }, + ]), readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })), - status: vi.fn(() => fallbackManagerStatus), + status: vi.fn(() => + createManagerStatus({ + backend: "builtin", + provider: "openai", + model: "text-embedding-3-small", + requestedProvider: "openai", + }), + ), sync: vi.fn(async () => {}), probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })), probeVectorAvailability: vi.fn(async () => true), close: vi.fn(async () => {}), -}; +})); -const mockMemoryIndexGet = vi.fn(async () => fallbackManager); +const fallbackSearch = fallbackManager.search; +const mockMemoryIndexGet = vi.hoisted(() => vi.fn(async () => fallbackManager)); +const mockCloseAllMemoryIndexManagers = vi.hoisted(() => vi.fn(async () => {})); vi.mock("./qmd-manager.js", () => ({ QmdMemoryManager: { @@ -83,14 +83,15 @@ vi.mock("./qmd-manager.js", () => ({ }, })); -vi.mock("./manager.js", () => ({ +vi.mock("./manager-runtime.js", () => ({ MemoryIndexManager: { get: mockMemoryIndexGet, }, + closeAllMemoryIndexManagers: mockCloseAllMemoryIndexManagers, })); import { QmdMemoryManager } from "./qmd-manager.js"; -import { getMemorySearchManager } from "./search-manager.js"; +import { closeAllMemorySearchManagers, getMemorySearchManager } from "./search-manager.js"; // eslint-disable-next-line @typescript-eslint/unbound-method -- mocked static function const createQmdManagerMock = vi.mocked(QmdMemoryManager.create); @@ -119,7 +120,8 @@ async function createFailedQmdSearchHarness(params: { agentId: string; errorMess return { cfg, manager: requireManager(first), firstResult: first }; } -beforeEach(() => { +beforeEach(async () => { + await closeAllMemorySearchManagers(); mockPrimary.search.mockClear(); mockPrimary.readFile.mockClear(); mockPrimary.status.mockClear(); @@ -134,6 +136,7 @@ beforeEach(() => { fallbackManager.probeEmbeddingAvailability.mockClear(); fallbackManager.probeVectorAvailability.mockClear(); fallbackManager.close.mockClear(); + mockCloseAllMemoryIndexManagers.mockClear(); mockMemoryIndexGet.mockClear(); mockMemoryIndexGet.mockResolvedValue(fallbackManager); createQmdManagerMock.mockClear(); @@ -243,4 +246,34 @@ describe("getMemorySearchManager caching", () => { await expect(firstManager.search("hello")).rejects.toThrow("qmd query failed"); }); + + it("closes cached managers on global teardown", async () => { + const cfg = createQmdCfg("teardown-agent"); + const first = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); + const firstManager = requireManager(first); + + await closeAllMemorySearchManagers(); + + expect(mockPrimary.close).toHaveBeenCalledTimes(1); + expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + + const second = await getMemorySearchManager({ cfg, agentId: "teardown-agent" }); + expect(second.manager).toBeTruthy(); + expect(second.manager).not.toBe(firstManager); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(createQmdManagerMock).toHaveBeenCalledTimes(2); + }); + + it("closes builtin index managers on teardown after runtime is loaded", async () => { + const retryAgentId = "teardown-with-fallback"; + const { manager } = await createFailedQmdSearchHarness({ + agentId: retryAgentId, + errorMessage: "qmd query failed", + }); + await manager.search("hello"); + + await closeAllMemorySearchManagers(); + + expect(mockCloseAllMemoryIndexManagers).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index f4e351fdc1a7b..ea581b5d6da57 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -85,6 +85,22 @@ export async function getMemorySearchManager(params: { } } +export async function closeAllMemorySearchManagers(): Promise { + const managers = Array.from(QMD_MANAGER_CACHE.values()); + QMD_MANAGER_CACHE.clear(); + for (const manager of managers) { + try { + await manager.close?.(); + } catch (err) { + log.warn(`failed to close qmd memory manager: ${String(err)}`); + } + } + if (managerRuntimePromise !== null) { + const { closeAllMemoryIndexManagers } = await loadManagerRuntime(); + await closeAllMemoryIndexManagers(); + } +} + class FallbackMemoryManager implements MemorySearchManager { private fallback: MemorySearchManager | null = null; private primaryFailed = false; From 5a659b0b61dbfa1645fdfa28bf9bffee03a8c9bc Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 17:49:06 -0500 Subject: [PATCH 299/311] feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self-contained chat modules extracted from dashboard-v2-structure: - chat/slash-commands.ts: slash command definitions and completions - chat/slash-command-executor.ts: execute slash commands via gateway RPC - chat/slash-command-executor.node.test.ts: test coverage - chat/speech.ts: speech-to-text (STT) support - chat/input-history.ts: per-session input history navigation - chat/pinned-messages.ts: pinned message management - chat/deleted-messages.ts: deleted message tracking - chat/export.ts: shared exportChatMarkdown helper - chat-export.ts: re-export shim for backwards compat Gateway fix: - Restore usage/cost stripping in chat.history sanitization - Add test coverage for sanitization behavior These modules are additive and tree-shaken — no existing code imports them yet. They will be wired in subsequent slices. --- src/gateway/server-methods/chat.ts | 93 ++++- .../server.chat.gateway-server-chat-b.test.ts | 31 ++ ui/src/ui/chat-export.ts | 1 + ui/src/ui/chat/deleted-messages.ts | 49 +++ ui/src/ui/chat/export.ts | 24 ++ ui/src/ui/chat/input-history.ts | 49 +++ ui/src/ui/chat/pinned-messages.ts | 61 +++ .../chat/slash-command-executor.node.test.ts | 83 ++++ ui/src/ui/chat/slash-command-executor.ts | 370 ++++++++++++++++++ ui/src/ui/chat/slash-commands.ts | 217 ++++++++++ ui/src/ui/chat/speech.ts | 225 +++++++++++ 11 files changed, 1196 insertions(+), 7 deletions(-) create mode 100644 ui/src/ui/chat-export.ts create mode 100644 ui/src/ui/chat/deleted-messages.ts create mode 100644 ui/src/ui/chat/export.ts create mode 100644 ui/src/ui/chat/input-history.ts create mode 100644 ui/src/ui/chat/pinned-messages.ts create mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts create mode 100644 ui/src/ui/chat/slash-command-executor.ts create mode 100644 ui/src/ui/chat/slash-commands.ts create mode 100644 ui/src/ui/chat/speech.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 7166908038214..291e323b67106 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,6 +314,60 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } +/** + * Validate that a value is a finite number, returning undefined otherwise. + */ +function toFiniteNumber(x: unknown): number | undefined { + return typeof x === "number" && Number.isFinite(x) ? x : undefined; +} + +/** + * Sanitize usage metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from malformed transcript JSON. + */ +function sanitizeUsage(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const u = raw as Record; + const out: Record = {}; + + // Whitelist known usage fields and validate they're finite numbers + const knownFields = [ + "input", + "output", + "totalTokens", + "inputTokens", + "outputTokens", + "cacheRead", + "cacheWrite", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ]; + + for (const k of knownFields) { + const n = toFiniteNumber(u[k]); + if (n !== undefined) { + out[k] = n; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Sanitize cost metadata to ensure only finite numeric fields are included. + * Prevents UI crashes from calling .toFixed() on non-numbers. + */ +function sanitizeCost(raw: unknown): { total?: number } | undefined { + if (!raw || typeof raw !== "object") { + return undefined; + } + const c = raw as Record; + const total = toFiniteNumber(c.total); + return total !== undefined ? { total } : undefined; +} + function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -325,13 +379,38 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; + + // Keep usage/cost so the chat UI can render per-message token and cost badges. + // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. + if (entry.role !== "assistant") { + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; + } + } else { + // Validate and sanitize usage/cost for assistant messages + if ("usage" in entry) { + const sanitized = sanitizeUsage(entry.usage); + if (sanitized) { + entry.usage = sanitized; + } else { + delete entry.usage; + } + changed = true; + } + if ("cost" in entry) { + const sanitized = sanitizeCost(entry.cost); + if (sanitized) { + entry.cost = sanitized; + } else { + delete entry.cost; + } + changed = true; + } } if (typeof entry.content === "string") { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 2e76e1a5de145..ca1e2c0940299 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -273,6 +273,37 @@ describe("gateway server chat", () => { }); }); + test("chat.history preserves usage and cost metadata for assistant messages", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + + const sessionDir = await createSessionDir(); + await writeMainSessionStore(); + + await writeMainSessionTranscript(sessionDir, [ + JSON.stringify({ + message: { + role: "assistant", + timestamp: Date.now(), + content: [{ type: "text", text: "hello" }], + usage: { input: 12, output: 5, totalTokens: 17 }, + cost: { total: 0.0123 }, + details: { debug: true }, + }, + }), + ]); + + const messages = await fetchHistoryMessages(ws); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "assistant", + usage: { input: 12, output: 5, totalTokens: 17 }, + cost: { total: 0.0123 }, + }); + expect(messages[0]).not.toHaveProperty("details"); + }); + }); + test("chat.history strips inline directives from displayed message text", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts new file mode 100644 index 0000000000000..ed5bbf931f8e1 --- /dev/null +++ b/ui/src/ui/chat-export.ts @@ -0,0 +1 @@ +export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts new file mode 100644 index 0000000000000..fd3916d78c7e6 --- /dev/null +++ b/ui/src/ui/chat/deleted-messages.ts @@ -0,0 +1,49 @@ +const PREFIX = "openclaw:deleted:"; + +export class DeletedMessages { + private key: string; + private _keys = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + has(key: string): boolean { + return this._keys.has(key); + } + + delete(key: string): void { + this._keys.add(key); + this.save(); + } + + restore(key: string): void { + this._keys.delete(key); + this.save(); + } + + clear(): void { + this._keys.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._keys = new Set(arr.filter((s) => typeof s === "string")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._keys])); + } +} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts new file mode 100644 index 0000000000000..31e15e592e2dd --- /dev/null +++ b/ui/src/ui/chat/export.ts @@ -0,0 +1,24 @@ +/** + * Export chat history as markdown file. + */ +export function exportChatMarkdown(messages: unknown[], assistantName: string): void { + const history = Array.isArray(messages) ? messages : []; + if (history.length === 0) { + return; + } + const lines: string[] = [`# Chat with ${assistantName}`, ""]; + for (const msg of history) { + const m = msg as Record; + const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; + const content = typeof m.content === "string" ? m.content : ""; + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; + lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `chat-${assistantName}-${Date.now()}.md`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts new file mode 100644 index 0000000000000..34d8806d07240 --- /dev/null +++ b/ui/src/ui/chat/input-history.ts @@ -0,0 +1,49 @@ +const MAX = 50; + +export class InputHistory { + private items: string[] = []; + private cursor = -1; + + push(text: string): void { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + if (this.items[this.items.length - 1] === trimmed) { + return; + } + this.items.push(trimmed); + if (this.items.length > MAX) { + this.items.shift(); + } + this.cursor = -1; + } + + up(): string | null { + if (this.items.length === 0) { + return null; + } + if (this.cursor < 0) { + this.cursor = this.items.length - 1; + } else if (this.cursor > 0) { + this.cursor--; + } + return this.items[this.cursor] ?? null; + } + + down(): string | null { + if (this.cursor < 0) { + return null; + } + this.cursor++; + if (this.cursor >= this.items.length) { + this.cursor = -1; + return null; + } + return this.items[this.cursor] ?? null; + } + + reset(): void { + this.cursor = -1; + } +} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts new file mode 100644 index 0000000000000..4914b0db32a1b --- /dev/null +++ b/ui/src/ui/chat/pinned-messages.ts @@ -0,0 +1,61 @@ +const PREFIX = "openclaw:pinned:"; + +export class PinnedMessages { + private key: string; + private _indices = new Set(); + + constructor(sessionKey: string) { + this.key = PREFIX + sessionKey; + this.load(); + } + + get indices(): Set { + return this._indices; + } + + has(index: number): boolean { + return this._indices.has(index); + } + + pin(index: number): void { + this._indices.add(index); + this.save(); + } + + unpin(index: number): void { + this._indices.delete(index); + this.save(); + } + + toggle(index: number): void { + if (this._indices.has(index)) { + this.unpin(index); + } else { + this.pin(index); + } + } + + clear(): void { + this._indices.clear(); + this.save(); + } + + private load(): void { + try { + const raw = localStorage.getItem(this.key); + if (!raw) { + return; + } + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + this._indices = new Set(arr.filter((n) => typeof n === "number")); + } + } catch { + // ignore + } + } + + private save(): void { + localStorage.setItem(this.key, JSON.stringify([...this._indices])); + } +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts new file mode 100644 index 0000000000000..706bfed0c3ccc --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { GatewaySessionRow } from "../types.ts"; +import { executeSlashCommand } from "./slash-command-executor.ts"; + +function row(key: string): GatewaySessionRow { + return { + key, + kind: "direct", + updatedAt: null, + }; +} + +describe("executeSlashCommand /kill", () => { + it("aborts every sub-agent session for /kill all", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + row("agent:main:subagent:one"), + row("agent:main:subagent:parent:subagent:child"), + row("agent:other:main"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:parent:subagent:child", + }); + }); + + it("aborts matching sub-agent sessions for /kill ", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one"), + row("agent:main:subagent:two"), + row("agent:other:subagent:three"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "main", + ); + + expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts new file mode 100644 index 0000000000000..3392095c7c1be --- /dev/null +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -0,0 +1,370 @@ +/** + * Client-side execution engine for slash commands. + * Calls gateway RPC methods and returns formatted results. + */ + +import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; +import type { GatewayBrowserClient } from "../gateway.ts"; +import type { + AgentsListResult, + GatewaySessionRow, + HealthSummary, + ModelCatalogEntry, + SessionsListResult, +} from "../types.ts"; +import { SLASH_COMMANDS } from "./slash-commands.ts"; + +export type SlashCommandResult = { + /** Markdown-formatted result to display in chat. */ + content: string; + /** Side-effect action the caller should perform after displaying the result. */ + action?: + | "refresh" + | "export" + | "new-session" + | "reset" + | "stop" + | "clear" + | "toggle-focus" + | "navigate-usage"; +}; + +export async function executeSlashCommand( + client: GatewayBrowserClient, + sessionKey: string, + commandName: string, + args: string, +): Promise { + switch (commandName) { + case "help": + return executeHelp(); + case "status": + return await executeStatus(client); + case "new": + return { content: "Starting new session...", action: "new-session" }; + case "reset": + return { content: "Resetting session...", action: "reset" }; + case "stop": + return { content: "Stopping current run...", action: "stop" }; + case "clear": + return { content: "Chat history cleared.", action: "clear" }; + case "focus": + return { content: "Toggled focus mode.", action: "toggle-focus" }; + case "compact": + return await executeCompact(client, sessionKey); + case "model": + return await executeModel(client, sessionKey, args); + case "think": + return await executeThink(client, sessionKey, args); + case "verbose": + return await executeVerbose(client, sessionKey, args); + case "export": + return { content: "Exporting session...", action: "export" }; + case "usage": + return await executeUsage(client, sessionKey); + case "agents": + return await executeAgents(client); + case "kill": + return await executeKill(client, sessionKey, args); + default: + return { content: `Unknown command: \`/${commandName}\`` }; + } +} + +// ── Command Implementations ── + +function executeHelp(): SlashCommandResult { + const lines = ["**Available Commands**\n"]; + let currentCategory = ""; + + for (const cmd of SLASH_COMMANDS) { + const cat = cmd.category ?? "session"; + if (cat !== currentCategory) { + currentCategory = cat; + lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); + } + const argStr = cmd.args ? ` ${cmd.args}` : ""; + const local = cmd.executeLocal ? "" : " *(agent)*"; + lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); + } + + lines.push("\nType `/` to open the command menu."); + return { content: lines.join("\n") }; +} + +async function executeStatus(client: GatewayBrowserClient): Promise { + try { + const health = await client.request("health", {}); + const status = health.ok ? "Healthy" : "Degraded"; + const agentCount = health.agents?.length ?? 0; + const sessionCount = health.sessions?.count ?? 0; + const lines = [ + `**System Status:** ${status}`, + `**Agents:** ${agentCount}`, + `**Sessions:** ${sessionCount}`, + `**Default Agent:** ${health.defaultAgentId || "none"}`, + ]; + if (health.durationMs) { + lines.push(`**Response:** ${health.durationMs}ms`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to fetch status: ${String(err)}` }; + } +} + +async function executeCompact( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + await client.request("sessions.compact", { key: sessionKey }); + return { content: "Context compacted successfully.", action: "refresh" }; + } catch (err) { + return { content: `Compaction failed: ${String(err)}` }; + } +} + +async function executeModel( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + if (!args) { + try { + const sessions = await client.request("sessions.list", {}); + const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); + const model = session?.model || sessions?.defaults?.model || "default"; + const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); + const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; + const lines = [`**Current model:** \`${model}\``]; + if (available.length > 0) { + lines.push( + `**Available:** ${available + .slice(0, 10) + .map((m: string) => `\`${m}\``) + .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, + ); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get model info: ${String(err)}` }; + } + } + + try { + await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; + } catch (err) { + return { content: `Failed to set model: ${String(err)}` }; + } +} + +async function executeThink( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const valid = ["off", "low", "medium", "high"]; + const level = args.trim().toLowerCase(); + + if (!level) { + return { + content: `Usage: \`/think <${valid.join("|")}>\``, + }; + } + if (!valid.includes(level)) { + return { + content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); + return { + content: `Thinking level set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set thinking level: ${String(err)}` }; + } +} + +async function executeVerbose( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const valid = ["on", "off", "full"]; + const level = args.trim().toLowerCase(); + + if (!level) { + return { + content: `Usage: \`/verbose <${valid.join("|")}>\``, + }; + } + if (!valid.includes(level)) { + return { + content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); + return { + content: `Verbose mode set to **${level}**.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set verbose mode: ${String(err)}` }; + } +} + +async function executeUsage( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + try { + const sessions = await client.request("sessions.list", {}); + const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); + if (!session) { + return { content: "No active session." }; + } + const input = session.inputTokens ?? 0; + const output = session.outputTokens ?? 0; + const total = session.totalTokens ?? input + output; + const ctx = session.contextTokens ?? 0; + const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; + + const lines = [ + "**Session Usage**", + `Input: **${fmtTokens(input)}** tokens`, + `Output: **${fmtTokens(output)}** tokens`, + `Total: **${fmtTokens(total)}** tokens`, + ]; + if (pct !== null) { + lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); + } + if (session.model) { + lines.push(`Model: \`${session.model}\``); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to get usage: ${String(err)}` }; + } +} + +async function executeAgents(client: GatewayBrowserClient): Promise { + try { + const result = await client.request("agents.list", {}); + const agents = result?.agents ?? []; + if (agents.length === 0) { + return { content: "No agents configured." }; + } + const lines = [`**Agents** (${agents.length})\n`]; + for (const agent of agents) { + const isDefault = agent.id === result?.defaultId; + const name = agent.identity?.name || agent.name || agent.id; + const marker = isDefault ? " *(default)*" : ""; + lines.push(`- \`${agent.id}\` — ${name}${marker}`); + } + return { content: lines.join("\n") }; + } catch (err) { + return { content: `Failed to list agents: ${String(err)}` }; + } +} + +async function executeKill( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const target = args.trim(); + if (!target) { + return { content: "Usage: `/kill `" }; + } + try { + const sessions = await client.request("sessions.list", {}); + const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); + if (matched.length === 0) { + return { + content: + target.toLowerCase() === "all" + ? "No active sub-agent sessions found." + : `No matching sub-agent sessions found for \`${target}\`.`, + }; + } + + const results = await Promise.allSettled( + matched.map((key) => client.request("chat.abort", { sessionKey: key })), + ); + const successCount = results.filter((entry) => entry.status === "fulfilled").length; + if (successCount === 0) { + const firstFailure = results.find((entry) => entry.status === "rejected"); + throw firstFailure?.reason ?? new Error("abort failed"); + } + + if (target.toLowerCase() === "all") { + return { + content: + successCount === matched.length + ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` + : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, + }; + } + + return { + content: + successCount === matched.length + ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` + : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, + }; + } catch (err) { + return { content: `Failed to abort: ${String(err)}` }; + } +} + +function resolveKillTargets( + sessions: GatewaySessionRow[], + currentSessionKey: string, + target: string, +): string[] { + const normalizedTarget = target.trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + + const keys = new Set(); + const currentParsed = parseAgentSessionKey(currentSessionKey); + for (const session of sessions) { + const key = session?.key?.trim(); + if (!key || !isSubagentSessionKey(key)) { + continue; + } + const normalizedKey = key.toLowerCase(); + const parsed = parseAgentSessionKey(normalizedKey); + const isMatch = + normalizedTarget === "all" || + normalizedKey === normalizedTarget || + (parsed?.agentId ?? "") === normalizedTarget || + normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || + normalizedKey === `subagent:${normalizedTarget}` || + (currentParsed?.agentId != null && + parsed?.agentId === currentParsed.agentId && + normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + if (isMatch) { + keys.add(key); + } + } + return [...keys]; +} + +function fmtTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts new file mode 100644 index 0000000000000..d26a82e1544b5 --- /dev/null +++ b/ui/src/ui/chat/slash-commands.ts @@ -0,0 +1,217 @@ +import type { IconName } from "../icons.ts"; + +export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; + +export type SlashCommandDef = { + name: string; + description: string; + args?: string; + icon?: IconName; + category?: SlashCommandCategory; + /** When true, the command is executed client-side via RPC instead of sent to the agent. */ + executeLocal?: boolean; + /** Fixed argument choices for inline hints. */ + argOptions?: string[]; + /** Keyboard shortcut hint shown in the menu (display only). */ + shortcut?: string; +}; + +export const SLASH_COMMANDS: SlashCommandDef[] = [ + // ── Session ── + { + name: "new", + description: "Start a new session", + icon: "plus", + category: "session", + executeLocal: true, + }, + { + name: "reset", + description: "Reset current session", + icon: "refresh", + category: "session", + executeLocal: true, + }, + { + name: "compact", + description: "Compact session context", + icon: "loader", + category: "session", + executeLocal: true, + }, + { + name: "stop", + description: "Stop current run", + icon: "stop", + category: "session", + executeLocal: true, + }, + { + name: "clear", + description: "Clear chat history", + icon: "trash", + category: "session", + executeLocal: true, + }, + { + name: "focus", + description: "Toggle focus mode", + icon: "eye", + category: "session", + executeLocal: true, + }, + + // ── Model ── + { + name: "model", + description: "Show or set model", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + }, + { + name: "think", + description: "Set thinking level", + args: "", + icon: "brain", + category: "model", + executeLocal: true, + argOptions: ["off", "low", "medium", "high"], + }, + { + name: "verbose", + description: "Toggle verbose mode", + args: "", + icon: "terminal", + category: "model", + executeLocal: true, + argOptions: ["on", "off", "full"], + }, + + // ── Tools ── + { + name: "help", + description: "Show available commands", + icon: "book", + category: "tools", + executeLocal: true, + }, + { + name: "status", + description: "Show system status", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + { + name: "export", + description: "Export session to Markdown", + icon: "download", + category: "tools", + executeLocal: true, + }, + { + name: "usage", + description: "Show token usage", + icon: "barChart", + category: "tools", + executeLocal: true, + }, + + // ── Agents ── + { + name: "agents", + description: "List agents", + icon: "monitor", + category: "agents", + executeLocal: true, + }, + { + name: "kill", + description: "Abort sub-agents", + args: "", + icon: "x", + category: "agents", + executeLocal: true, + }, + { + name: "skill", + description: "Run a skill", + args: "", + icon: "zap", + category: "tools", + }, + { + name: "steer", + description: "Steer a sub-agent", + args: " ", + icon: "send", + category: "agents", + }, +]; + +const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; + +export const CATEGORY_LABELS: Record = { + session: "Session", + model: "Model", + agents: "Agents", + tools: "Tools", +}; + +export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { + const lower = filter.toLowerCase(); + const commands = lower + ? SLASH_COMMANDS.filter( + (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), + ) + : SLASH_COMMANDS; + return commands.toSorted((a, b) => { + const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); + const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); + if (ai !== bi) { + return ai - bi; + } + // Exact prefix matches first + if (lower) { + const aExact = a.name.startsWith(lower) ? 0 : 1; + const bExact = b.name.startsWith(lower) ? 0 : 1; + if (aExact !== bExact) { + return aExact - bExact; + } + } + return 0; + }); +} + +export type ParsedSlashCommand = { + command: SlashCommandDef; + args: string; +}; + +/** + * Parse a message as a slash command. Returns null if it doesn't match. + * Supports `/command` and `/command args...`. + */ +export function parseSlashCommand(text: string): ParsedSlashCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const spaceIdx = trimmed.indexOf(" "); + const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); + const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + + if (!name) { + return null; + } + + const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); + if (!command) { + return null; + } + + return { command, args }; +} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts new file mode 100644 index 0000000000000..4db4e6944a1f0 --- /dev/null +++ b/ui/src/ui/chat/speech.ts @@ -0,0 +1,225 @@ +/** + * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. + * Falls back gracefully when APIs are unavailable. + */ + +// ─── STT (Speech-to-Text) ─── + +type SpeechRecognitionEvent = Event & { + results: SpeechRecognitionResultList; + resultIndex: number; +}; + +type SpeechRecognitionErrorEvent = Event & { + error: string; + message?: string; +}; + +interface SpeechRecognitionInstance extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + abort(): void; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; + onend: (() => void) | null; + onstart: (() => void) | null; +} + +type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + const w = globalThis as Record; + return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; +} + +export function isSttSupported(): boolean { + return getSpeechRecognitionCtor() !== null; +} + +export type SttCallbacks = { + onTranscript: (text: string, isFinal: boolean) => void; + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; +}; + +let activeRecognition: SpeechRecognitionInstance | null = null; + +export function startStt(callbacks: SttCallbacks): boolean { + const Ctor = getSpeechRecognitionCtor(); + if (!Ctor) { + callbacks.onError?.("Speech recognition is not supported in this browser"); + return false; + } + + stopStt(); + + const recognition = new Ctor(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = navigator.language || "en-US"; + + recognition.addEventListener("start", () => callbacks.onStart?.()); + + recognition.addEventListener("result", (event) => { + const speechEvent = event as unknown as SpeechRecognitionEvent; + let interimTranscript = ""; + let finalTranscript = ""; + + for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { + const result = speechEvent.results[i]; + if (!result?.[0]) { + continue; + } + const transcript = result[0].transcript; + if (result.isFinal) { + finalTranscript += transcript; + } else { + interimTranscript += transcript; + } + } + + if (finalTranscript) { + callbacks.onTranscript(finalTranscript, true); + } else if (interimTranscript) { + callbacks.onTranscript(interimTranscript, false); + } + }); + + recognition.addEventListener("error", (event) => { + const speechEvent = event as unknown as SpeechRecognitionErrorEvent; + if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { + return; + } + callbacks.onError?.(speechEvent.error); + }); + + recognition.addEventListener("end", () => { + if (activeRecognition === recognition) { + activeRecognition = null; + } + callbacks.onEnd?.(); + }); + + activeRecognition = recognition; + recognition.start(); + return true; +} + +export function stopStt(): void { + if (activeRecognition) { + const r = activeRecognition; + activeRecognition = null; + try { + r.stop(); + } catch { + // already stopped + } + } +} + +export function isSttActive(): boolean { + return activeRecognition !== null; +} + +// ─── TTS (Text-to-Speech) ─── + +export function isTtsSupported(): boolean { + return "speechSynthesis" in globalThis; +} + +let currentUtterance: SpeechSynthesisUtterance | null = null; + +export function speakText( + text: string, + opts?: { + onStart?: () => void; + onEnd?: () => void; + onError?: (error: string) => void; + }, +): boolean { + if (!isTtsSupported()) { + opts?.onError?.("Speech synthesis is not supported in this browser"); + return false; + } + + stopTts(); + + const cleaned = stripMarkdown(text); + if (!cleaned.trim()) { + return false; + } + + const utterance = new SpeechSynthesisUtterance(cleaned); + utterance.rate = 1.0; + utterance.pitch = 1.0; + + utterance.addEventListener("start", () => opts?.onStart?.()); + utterance.addEventListener("end", () => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + opts?.onEnd?.(); + }); + utterance.addEventListener("error", (e) => { + if (currentUtterance === utterance) { + currentUtterance = null; + } + if (e.error === "canceled" || e.error === "interrupted") { + return; + } + opts?.onError?.(e.error); + }); + + currentUtterance = utterance; + speechSynthesis.speak(utterance); + return true; +} + +export function stopTts(): void { + if (currentUtterance) { + currentUtterance = null; + } + if (isTtsSupported()) { + speechSynthesis.cancel(); + } +} + +export function isTtsSpeaking(): boolean { + return isTtsSupported() && speechSynthesis.speaking; +} + +/** Strip common markdown syntax for cleaner speech output. */ +function stripMarkdown(text: string): string { + return ( + text + // code blocks + .replace(/```[\s\S]*?```/g, "") + // inline code + .replace(/`[^`]+`/g, "") + // images + .replace(/!\[.*?\]\(.*?\)/g, "") + // links → keep text + .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") + // headings + .replace(/^#{1,6}\s+/gm, "") + // bold/italic + .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") + .replace(/_{1,3}(.*?)_{1,3}/g, "$1") + // blockquotes + .replace(/^>\s?/gm, "") + // horizontal rules + .replace(/^[-*_]{3,}\s*$/gm, "") + // list markers + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // HTML tags + .replace(/<[^>]+>/g, "") + // collapse whitespace + .replace(/\n{3,}/g, "\n\n") + .trim() + ); +} From d648dd7643dc1232cc1a9071391fad0587097ca8 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:07:03 -0500 Subject: [PATCH 300/311] Update ui/src/ui/chat/export.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/src/ui/chat/export.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts index 31e15e592e2dd..365d640ffcc55 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/ui/chat/export.ts @@ -10,7 +10,15 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string): for (const msg of history) { const m = msg as Record; const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; + const content = + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? (m.content as Array<{ type?: string; text?: string }>) + .filter((b) => b?.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("") + : ""; const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); } From 8a6cd808a138ea73e53b7498bc3fd5dcfd565a7a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:09:37 -0500 Subject: [PATCH 301/311] fix(ui): address review feedback on chat infra slice - export.ts: handle array content blocks (Claude API format) instead of silently exporting empty strings - slash-command-executor.ts: restrict /kill all to current session's subagent subtree instead of all sessions globally - slash-command-executor.ts: only count truly aborted runs (check aborted !== false) in /kill summary --- ui/src/ui/chat/slash-command-executor.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 3392095c7c1be..d1c767370a44c 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -296,9 +296,14 @@ async function executeKill( } const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), + matched.map((key) => + client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), + ), ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; + const successCount = results.filter( + (entry) => + entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, + ).length; if (successCount === 0) { const firstFailure = results.find((entry) => entry.status === "rejected"); throw firstFailure?.reason ?? new Error("abort failed"); @@ -343,15 +348,16 @@ function resolveKillTargets( } const normalizedKey = key.toLowerCase(); const parsed = parseAgentSessionKey(normalizedKey); + // For "all", only match subagents belonging to the current session's agent + const belongsToCurrentSession = + currentParsed?.agentId != null && parsed?.agentId === currentParsed.agentId; const isMatch = - normalizedTarget === "all" || + (normalizedTarget === "all" && belongsToCurrentSession) || normalizedKey === normalizedTarget || (parsed?.agentId ?? "") === normalizedTarget || normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + (belongsToCurrentSession && normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); if (isMatch) { keys.add(key); } From 8e412bad0ebe41264dc4cf169b1fdd8453f6b000 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:37 -0500 Subject: [PATCH 302/311] Revert "fix(ui): address review feedback on chat infra slice" This reverts commit 8a6cd808a138ea73e53b7498bc3fd5dcfd565a7a. --- ui/src/ui/chat/slash-command-executor.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index d1c767370a44c..3392095c7c1be 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -296,14 +296,9 @@ async function executeKill( } const results = await Promise.allSettled( - matched.map((key) => - client.request<{ aborted?: boolean }>("chat.abort", { sessionKey: key }), - ), + matched.map((key) => client.request("chat.abort", { sessionKey: key })), ); - const successCount = results.filter( - (entry) => - entry.status === "fulfilled" && (entry.value as { aborted?: boolean })?.aborted !== false, - ).length; + const successCount = results.filter((entry) => entry.status === "fulfilled").length; if (successCount === 0) { const firstFailure = results.find((entry) => entry.status === "rejected"); throw firstFailure?.reason ?? new Error("abort failed"); @@ -348,16 +343,15 @@ function resolveKillTargets( } const normalizedKey = key.toLowerCase(); const parsed = parseAgentSessionKey(normalizedKey); - // For "all", only match subagents belonging to the current session's agent - const belongsToCurrentSession = - currentParsed?.agentId != null && parsed?.agentId === currentParsed.agentId; const isMatch = - (normalizedTarget === "all" && belongsToCurrentSession) || + normalizedTarget === "all" || normalizedKey === normalizedTarget || (parsed?.agentId ?? "") === normalizedTarget || normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || normalizedKey === `subagent:${normalizedTarget}` || - (belongsToCurrentSession && normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); + (currentParsed?.agentId != null && + parsed?.agentId === currentParsed.agentId && + normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); if (isMatch) { keys.add(key); } From 9f0a64f855439979abd79a6e9e52d171c994482f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:40 -0500 Subject: [PATCH 303/311] Revert "Update ui/src/ui/chat/export.ts" This reverts commit d648dd7643dc1232cc1a9071391fad0587097ca8. --- ui/src/ui/chat/export.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts index 365d640ffcc55..31e15e592e2dd 100644 --- a/ui/src/ui/chat/export.ts +++ b/ui/src/ui/chat/export.ts @@ -10,15 +10,7 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string): for (const msg of history) { const m = msg as Record; const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = - typeof m.content === "string" - ? m.content - : Array.isArray(m.content) - ? (m.content as Array<{ type?: string; text?: string }>) - .filter((b) => b?.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join("") - : ""; + const content = typeof m.content === "string" ? m.content : ""; const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); } From 6b8748989061c1d3405002e67004cd7574042717 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 9 Mar 2026 18:47:44 -0500 Subject: [PATCH 304/311] Revert "feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)" This reverts commit 5a659b0b61dbfa1645fdfa28bf9bffee03a8c9bc. --- src/gateway/server-methods/chat.ts | 93 +---- .../server.chat.gateway-server-chat-b.test.ts | 31 -- ui/src/ui/chat-export.ts | 1 - ui/src/ui/chat/deleted-messages.ts | 49 --- ui/src/ui/chat/export.ts | 24 -- ui/src/ui/chat/input-history.ts | 49 --- ui/src/ui/chat/pinned-messages.ts | 61 --- .../chat/slash-command-executor.node.test.ts | 83 ---- ui/src/ui/chat/slash-command-executor.ts | 370 ------------------ ui/src/ui/chat/slash-commands.ts | 217 ---------- ui/src/ui/chat/speech.ts | 225 ----------- 11 files changed, 7 insertions(+), 1196 deletions(-) delete mode 100644 ui/src/ui/chat-export.ts delete mode 100644 ui/src/ui/chat/deleted-messages.ts delete mode 100644 ui/src/ui/chat/export.ts delete mode 100644 ui/src/ui/chat/input-history.ts delete mode 100644 ui/src/ui/chat/pinned-messages.ts delete mode 100644 ui/src/ui/chat/slash-command-executor.node.test.ts delete mode 100644 ui/src/ui/chat/slash-command-executor.ts delete mode 100644 ui/src/ui/chat/slash-commands.ts delete mode 100644 ui/src/ui/chat/speech.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 291e323b67106..7166908038214 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -314,60 +314,6 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan return { block: changed ? entry : block, changed }; } -/** - * Validate that a value is a finite number, returning undefined otherwise. - */ -function toFiniteNumber(x: unknown): number | undefined { - return typeof x === "number" && Number.isFinite(x) ? x : undefined; -} - -/** - * Sanitize usage metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from malformed transcript JSON. - */ -function sanitizeUsage(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const u = raw as Record; - const out: Record = {}; - - // Whitelist known usage fields and validate they're finite numbers - const knownFields = [ - "input", - "output", - "totalTokens", - "inputTokens", - "outputTokens", - "cacheRead", - "cacheWrite", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ]; - - for (const k of knownFields) { - const n = toFiniteNumber(u[k]); - if (n !== undefined) { - out[k] = n; - } - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -/** - * Sanitize cost metadata to ensure only finite numeric fields are included. - * Prevents UI crashes from calling .toFixed() on non-numbers. - */ -function sanitizeCost(raw: unknown): { total?: number } | undefined { - if (!raw || typeof raw !== "object") { - return undefined; - } - const c = raw as Record; - const total = toFiniteNumber(c.total); - return total !== undefined ? { total } : undefined; -} - function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { if (!message || typeof message !== "object") { return { message, changed: false }; @@ -379,38 +325,13 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - - // Keep usage/cost so the chat UI can render per-message token and cost badges. - // Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes. - if (entry.role !== "assistant") { - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; - } - } else { - // Validate and sanitize usage/cost for assistant messages - if ("usage" in entry) { - const sanitized = sanitizeUsage(entry.usage); - if (sanitized) { - entry.usage = sanitized; - } else { - delete entry.usage; - } - changed = true; - } - if ("cost" in entry) { - const sanitized = sanitizeCost(entry.cost); - if (sanitized) { - entry.cost = sanitized; - } else { - delete entry.cost; - } - changed = true; - } + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; } if (typeof entry.content === "string") { diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index ca1e2c0940299..2e76e1a5de145 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -273,37 +273,6 @@ describe("gateway server chat", () => { }); }); - test("chat.history preserves usage and cost metadata for assistant messages", async () => { - await withGatewayChatHarness(async ({ ws, createSessionDir }) => { - await connectOk(ws); - - const sessionDir = await createSessionDir(); - await writeMainSessionStore(); - - await writeMainSessionTranscript(sessionDir, [ - JSON.stringify({ - message: { - role: "assistant", - timestamp: Date.now(), - content: [{ type: "text", text: "hello" }], - usage: { input: 12, output: 5, totalTokens: 17 }, - cost: { total: 0.0123 }, - details: { debug: true }, - }, - }), - ]); - - const messages = await fetchHistoryMessages(ws); - expect(messages).toHaveLength(1); - expect(messages[0]).toMatchObject({ - role: "assistant", - usage: { input: 12, output: 5, totalTokens: 17 }, - cost: { total: 0.0123 }, - }); - expect(messages[0]).not.toHaveProperty("details"); - }); - }); - test("chat.history strips inline directives from displayed message text", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); diff --git a/ui/src/ui/chat-export.ts b/ui/src/ui/chat-export.ts deleted file mode 100644 index ed5bbf931f8e1..0000000000000 --- a/ui/src/ui/chat-export.ts +++ /dev/null @@ -1 +0,0 @@ -export { exportChatMarkdown } from "./chat/export.ts"; diff --git a/ui/src/ui/chat/deleted-messages.ts b/ui/src/ui/chat/deleted-messages.ts deleted file mode 100644 index fd3916d78c7e6..0000000000000 --- a/ui/src/ui/chat/deleted-messages.ts +++ /dev/null @@ -1,49 +0,0 @@ -const PREFIX = "openclaw:deleted:"; - -export class DeletedMessages { - private key: string; - private _keys = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - has(key: string): boolean { - return this._keys.has(key); - } - - delete(key: string): void { - this._keys.add(key); - this.save(); - } - - restore(key: string): void { - this._keys.delete(key); - this.save(); - } - - clear(): void { - this._keys.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._keys = new Set(arr.filter((s) => typeof s === "string")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._keys])); - } -} diff --git a/ui/src/ui/chat/export.ts b/ui/src/ui/chat/export.ts deleted file mode 100644 index 31e15e592e2dd..0000000000000 --- a/ui/src/ui/chat/export.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Export chat history as markdown file. - */ -export function exportChatMarkdown(messages: unknown[], assistantName: string): void { - const history = Array.isArray(messages) ? messages : []; - if (history.length === 0) { - return; - } - const lines: string[] = [`# Chat with ${assistantName}`, ""]; - for (const msg of history) { - const m = msg as Record; - const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool"; - const content = typeof m.content === "string" ? m.content : ""; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : ""; - lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, ""); - } - const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `chat-${assistantName}-${Date.now()}.md`; - link.click(); - URL.revokeObjectURL(url); -} diff --git a/ui/src/ui/chat/input-history.ts b/ui/src/ui/chat/input-history.ts deleted file mode 100644 index 34d8806d07240..0000000000000 --- a/ui/src/ui/chat/input-history.ts +++ /dev/null @@ -1,49 +0,0 @@ -const MAX = 50; - -export class InputHistory { - private items: string[] = []; - private cursor = -1; - - push(text: string): void { - const trimmed = text.trim(); - if (!trimmed) { - return; - } - if (this.items[this.items.length - 1] === trimmed) { - return; - } - this.items.push(trimmed); - if (this.items.length > MAX) { - this.items.shift(); - } - this.cursor = -1; - } - - up(): string | null { - if (this.items.length === 0) { - return null; - } - if (this.cursor < 0) { - this.cursor = this.items.length - 1; - } else if (this.cursor > 0) { - this.cursor--; - } - return this.items[this.cursor] ?? null; - } - - down(): string | null { - if (this.cursor < 0) { - return null; - } - this.cursor++; - if (this.cursor >= this.items.length) { - this.cursor = -1; - return null; - } - return this.items[this.cursor] ?? null; - } - - reset(): void { - this.cursor = -1; - } -} diff --git a/ui/src/ui/chat/pinned-messages.ts b/ui/src/ui/chat/pinned-messages.ts deleted file mode 100644 index 4914b0db32a1b..0000000000000 --- a/ui/src/ui/chat/pinned-messages.ts +++ /dev/null @@ -1,61 +0,0 @@ -const PREFIX = "openclaw:pinned:"; - -export class PinnedMessages { - private key: string; - private _indices = new Set(); - - constructor(sessionKey: string) { - this.key = PREFIX + sessionKey; - this.load(); - } - - get indices(): Set { - return this._indices; - } - - has(index: number): boolean { - return this._indices.has(index); - } - - pin(index: number): void { - this._indices.add(index); - this.save(); - } - - unpin(index: number): void { - this._indices.delete(index); - this.save(); - } - - toggle(index: number): void { - if (this._indices.has(index)) { - this.unpin(index); - } else { - this.pin(index); - } - } - - clear(): void { - this._indices.clear(); - this.save(); - } - - private load(): void { - try { - const raw = localStorage.getItem(this.key); - if (!raw) { - return; - } - const arr = JSON.parse(raw); - if (Array.isArray(arr)) { - this._indices = new Set(arr.filter((n) => typeof n === "number")); - } - } catch { - // ignore - } - } - - private save(): void { - localStorage.setItem(this.key, JSON.stringify([...this._indices])); - } -} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts deleted file mode 100644 index 706bfed0c3ccc..0000000000000 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; -import { executeSlashCommand } from "./slash-command-executor.ts"; - -function row(key: string): GatewaySessionRow { - return { - key, - kind: "direct", - updatedAt: null, - }; -} - -describe("executeSlashCommand /kill", () => { - it("aborts every sub-agent session for /kill all", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:parent:subagent:child"), - row("agent:other:main"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "all", - ); - - expect(result.content).toBe("Aborted 2 sub-agent sessions."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:parent:subagent:child", - }); - }); - - it("aborts matching sub-agent sessions for /kill ", async () => { - const request = vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { - sessions: [ - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), - ], - }; - } - if (method === "chat.abort") { - return { ok: true, aborted: true }; - } - throw new Error(`unexpected method: ${method}`); - }); - - const result = await executeSlashCommand( - { request } as unknown as GatewayBrowserClient, - "agent:main:main", - "kill", - "main", - ); - - expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { - sessionKey: "agent:main:subagent:one", - }); - expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { - sessionKey: "agent:main:subagent:two", - }); - }); -}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts deleted file mode 100644 index 3392095c7c1be..0000000000000 --- a/ui/src/ui/chat/slash-command-executor.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Client-side execution engine for slash commands. - * Calls gateway RPC methods and returns formatted results. - */ - -import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js"; -import type { GatewayBrowserClient } from "../gateway.ts"; -import type { - AgentsListResult, - GatewaySessionRow, - HealthSummary, - ModelCatalogEntry, - SessionsListResult, -} from "../types.ts"; -import { SLASH_COMMANDS } from "./slash-commands.ts"; - -export type SlashCommandResult = { - /** Markdown-formatted result to display in chat. */ - content: string; - /** Side-effect action the caller should perform after displaying the result. */ - action?: - | "refresh" - | "export" - | "new-session" - | "reset" - | "stop" - | "clear" - | "toggle-focus" - | "navigate-usage"; -}; - -export async function executeSlashCommand( - client: GatewayBrowserClient, - sessionKey: string, - commandName: string, - args: string, -): Promise { - switch (commandName) { - case "help": - return executeHelp(); - case "status": - return await executeStatus(client); - case "new": - return { content: "Starting new session...", action: "new-session" }; - case "reset": - return { content: "Resetting session...", action: "reset" }; - case "stop": - return { content: "Stopping current run...", action: "stop" }; - case "clear": - return { content: "Chat history cleared.", action: "clear" }; - case "focus": - return { content: "Toggled focus mode.", action: "toggle-focus" }; - case "compact": - return await executeCompact(client, sessionKey); - case "model": - return await executeModel(client, sessionKey, args); - case "think": - return await executeThink(client, sessionKey, args); - case "verbose": - return await executeVerbose(client, sessionKey, args); - case "export": - return { content: "Exporting session...", action: "export" }; - case "usage": - return await executeUsage(client, sessionKey); - case "agents": - return await executeAgents(client); - case "kill": - return await executeKill(client, sessionKey, args); - default: - return { content: `Unknown command: \`/${commandName}\`` }; - } -} - -// ── Command Implementations ── - -function executeHelp(): SlashCommandResult { - const lines = ["**Available Commands**\n"]; - let currentCategory = ""; - - for (const cmd of SLASH_COMMANDS) { - const cat = cmd.category ?? "session"; - if (cat !== currentCategory) { - currentCategory = cat; - lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`); - } - const argStr = cmd.args ? ` ${cmd.args}` : ""; - const local = cmd.executeLocal ? "" : " *(agent)*"; - lines.push(`\`/${cmd.name}${argStr}\` — ${cmd.description}${local}`); - } - - lines.push("\nType `/` to open the command menu."); - return { content: lines.join("\n") }; -} - -async function executeStatus(client: GatewayBrowserClient): Promise { - try { - const health = await client.request("health", {}); - const status = health.ok ? "Healthy" : "Degraded"; - const agentCount = health.agents?.length ?? 0; - const sessionCount = health.sessions?.count ?? 0; - const lines = [ - `**System Status:** ${status}`, - `**Agents:** ${agentCount}`, - `**Sessions:** ${sessionCount}`, - `**Default Agent:** ${health.defaultAgentId || "none"}`, - ]; - if (health.durationMs) { - lines.push(`**Response:** ${health.durationMs}ms`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to fetch status: ${String(err)}` }; - } -} - -async function executeCompact( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - await client.request("sessions.compact", { key: sessionKey }); - return { content: "Context compacted successfully.", action: "refresh" }; - } catch (err) { - return { content: `Compaction failed: ${String(err)}` }; - } -} - -async function executeModel( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - if (!args) { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - const model = session?.model || sessions?.defaults?.model || "default"; - const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {}); - const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? []; - const lines = [`**Current model:** \`${model}\``]; - if (available.length > 0) { - lines.push( - `**Available:** ${available - .slice(0, 10) - .map((m: string) => `\`${m}\``) - .join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`, - ); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get model info: ${String(err)}` }; - } - } - - try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); - return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" }; - } catch (err) { - return { content: `Failed to set model: ${String(err)}` }; - } -} - -async function executeThink( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["off", "low", "medium", "high"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/think <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level }); - return { - content: `Thinking level set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set thinking level: ${String(err)}` }; - } -} - -async function executeVerbose( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const valid = ["on", "off", "full"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/verbose <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; - } - - try { - await client.request("sessions.patch", { key: sessionKey, verboseLevel: level }); - return { - content: `Verbose mode set to **${level}**.`, - action: "refresh", - }; - } catch (err) { - return { content: `Failed to set verbose mode: ${String(err)}` }; - } -} - -async function executeUsage( - client: GatewayBrowserClient, - sessionKey: string, -): Promise { - try { - const sessions = await client.request("sessions.list", {}); - const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey); - if (!session) { - return { content: "No active session." }; - } - const input = session.inputTokens ?? 0; - const output = session.outputTokens ?? 0; - const total = session.totalTokens ?? input + output; - const ctx = session.contextTokens ?? 0; - const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; - - const lines = [ - "**Session Usage**", - `Input: **${fmtTokens(input)}** tokens`, - `Output: **${fmtTokens(output)}** tokens`, - `Total: **${fmtTokens(total)}** tokens`, - ]; - if (pct !== null) { - lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); - } - if (session.model) { - lines.push(`Model: \`${session.model}\``); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to get usage: ${String(err)}` }; - } -} - -async function executeAgents(client: GatewayBrowserClient): Promise { - try { - const result = await client.request("agents.list", {}); - const agents = result?.agents ?? []; - if (agents.length === 0) { - return { content: "No agents configured." }; - } - const lines = [`**Agents** (${agents.length})\n`]; - for (const agent of agents) { - const isDefault = agent.id === result?.defaultId; - const name = agent.identity?.name || agent.name || agent.id; - const marker = isDefault ? " *(default)*" : ""; - lines.push(`- \`${agent.id}\` — ${name}${marker}`); - } - return { content: lines.join("\n") }; - } catch (err) { - return { content: `Failed to list agents: ${String(err)}` }; - } -} - -async function executeKill( - client: GatewayBrowserClient, - sessionKey: string, - args: string, -): Promise { - const target = args.trim(); - if (!target) { - return { content: "Usage: `/kill `" }; - } - try { - const sessions = await client.request("sessions.list", {}); - const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target); - if (matched.length === 0) { - return { - content: - target.toLowerCase() === "all" - ? "No active sub-agent sessions found." - : `No matching sub-agent sessions found for \`${target}\`.`, - }; - } - - const results = await Promise.allSettled( - matched.map((key) => client.request("chat.abort", { sessionKey: key })), - ); - const successCount = results.filter((entry) => entry.status === "fulfilled").length; - if (successCount === 0) { - const firstFailure = results.find((entry) => entry.status === "rejected"); - throw firstFailure?.reason ?? new Error("abort failed"); - } - - if (target.toLowerCase() === "all") { - return { - content: - successCount === matched.length - ? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.` - : `Aborted ${successCount} of ${matched.length} sub-agent sessions.`, - }; - } - - return { - content: - successCount === matched.length - ? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.` - : `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`, - }; - } catch (err) { - return { content: `Failed to abort: ${String(err)}` }; - } -} - -function resolveKillTargets( - sessions: GatewaySessionRow[], - currentSessionKey: string, - target: string, -): string[] { - const normalizedTarget = target.trim().toLowerCase(); - if (!normalizedTarget) { - return []; - } - - const keys = new Set(); - const currentParsed = parseAgentSessionKey(currentSessionKey); - for (const session of sessions) { - const key = session?.key?.trim(); - if (!key || !isSubagentSessionKey(key)) { - continue; - } - const normalizedKey = key.toLowerCase(); - const parsed = parseAgentSessionKey(normalizedKey); - const isMatch = - normalizedTarget === "all" || - normalizedKey === normalizedTarget || - (parsed?.agentId ?? "") === normalizedTarget || - normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || - normalizedKey === `subagent:${normalizedTarget}` || - (currentParsed?.agentId != null && - parsed?.agentId === currentParsed.agentId && - normalizedKey.endsWith(`:subagent:${normalizedTarget}`)); - if (isMatch) { - keys.add(key); - } - } - return [...keys]; -} - -function fmtTokens(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (n >= 1_000) { - return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(n); -} diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts deleted file mode 100644 index d26a82e1544b5..0000000000000 --- a/ui/src/ui/chat/slash-commands.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { IconName } from "../icons.ts"; - -export type SlashCommandCategory = "session" | "model" | "agents" | "tools"; - -export type SlashCommandDef = { - name: string; - description: string; - args?: string; - icon?: IconName; - category?: SlashCommandCategory; - /** When true, the command is executed client-side via RPC instead of sent to the agent. */ - executeLocal?: boolean; - /** Fixed argument choices for inline hints. */ - argOptions?: string[]; - /** Keyboard shortcut hint shown in the menu (display only). */ - shortcut?: string; -}; - -export const SLASH_COMMANDS: SlashCommandDef[] = [ - // ── Session ── - { - name: "new", - description: "Start a new session", - icon: "plus", - category: "session", - executeLocal: true, - }, - { - name: "reset", - description: "Reset current session", - icon: "refresh", - category: "session", - executeLocal: true, - }, - { - name: "compact", - description: "Compact session context", - icon: "loader", - category: "session", - executeLocal: true, - }, - { - name: "stop", - description: "Stop current run", - icon: "stop", - category: "session", - executeLocal: true, - }, - { - name: "clear", - description: "Clear chat history", - icon: "trash", - category: "session", - executeLocal: true, - }, - { - name: "focus", - description: "Toggle focus mode", - icon: "eye", - category: "session", - executeLocal: true, - }, - - // ── Model ── - { - name: "model", - description: "Show or set model", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - }, - { - name: "think", - description: "Set thinking level", - args: "", - icon: "brain", - category: "model", - executeLocal: true, - argOptions: ["off", "low", "medium", "high"], - }, - { - name: "verbose", - description: "Toggle verbose mode", - args: "", - icon: "terminal", - category: "model", - executeLocal: true, - argOptions: ["on", "off", "full"], - }, - - // ── Tools ── - { - name: "help", - description: "Show available commands", - icon: "book", - category: "tools", - executeLocal: true, - }, - { - name: "status", - description: "Show system status", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - { - name: "export", - description: "Export session to Markdown", - icon: "download", - category: "tools", - executeLocal: true, - }, - { - name: "usage", - description: "Show token usage", - icon: "barChart", - category: "tools", - executeLocal: true, - }, - - // ── Agents ── - { - name: "agents", - description: "List agents", - icon: "monitor", - category: "agents", - executeLocal: true, - }, - { - name: "kill", - description: "Abort sub-agents", - args: "", - icon: "x", - category: "agents", - executeLocal: true, - }, - { - name: "skill", - description: "Run a skill", - args: "", - icon: "zap", - category: "tools", - }, - { - name: "steer", - description: "Steer a sub-agent", - args: " ", - icon: "send", - category: "agents", - }, -]; - -const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"]; - -export const CATEGORY_LABELS: Record = { - session: "Session", - model: "Model", - agents: "Agents", - tools: "Tools", -}; - -export function getSlashCommandCompletions(filter: string): SlashCommandDef[] { - const lower = filter.toLowerCase(); - const commands = lower - ? SLASH_COMMANDS.filter( - (cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower), - ) - : SLASH_COMMANDS; - return commands.toSorted((a, b) => { - const ai = CATEGORY_ORDER.indexOf(a.category ?? "session"); - const bi = CATEGORY_ORDER.indexOf(b.category ?? "session"); - if (ai !== bi) { - return ai - bi; - } - // Exact prefix matches first - if (lower) { - const aExact = a.name.startsWith(lower) ? 0 : 1; - const bExact = b.name.startsWith(lower) ? 0 : 1; - if (aExact !== bExact) { - return aExact - bExact; - } - } - return 0; - }); -} - -export type ParsedSlashCommand = { - command: SlashCommandDef; - args: string; -}; - -/** - * Parse a message as a slash command. Returns null if it doesn't match. - * Supports `/command` and `/command args...`. - */ -export function parseSlashCommand(text: string): ParsedSlashCommand | null { - const trimmed = text.trim(); - if (!trimmed.startsWith("/")) { - return null; - } - - const spaceIdx = trimmed.indexOf(" "); - const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); - const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); - - if (!name) { - return null; - } - - const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase()); - if (!command) { - return null; - } - - return { command, args }; -} diff --git a/ui/src/ui/chat/speech.ts b/ui/src/ui/chat/speech.ts deleted file mode 100644 index 4db4e6944a1f0..0000000000000 --- a/ui/src/ui/chat/speech.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis. - * Falls back gracefully when APIs are unavailable. - */ - -// ─── STT (Speech-to-Text) ─── - -type SpeechRecognitionEvent = Event & { - results: SpeechRecognitionResultList; - resultIndex: number; -}; - -type SpeechRecognitionErrorEvent = Event & { - error: string; - message?: string; -}; - -interface SpeechRecognitionInstance extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - abort(): void; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; - onend: (() => void) | null; - onstart: (() => void) | null; -} - -type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; - -function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { - const w = globalThis as Record; - return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null; -} - -export function isSttSupported(): boolean { - return getSpeechRecognitionCtor() !== null; -} - -export type SttCallbacks = { - onTranscript: (text: string, isFinal: boolean) => void; - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; -}; - -let activeRecognition: SpeechRecognitionInstance | null = null; - -export function startStt(callbacks: SttCallbacks): boolean { - const Ctor = getSpeechRecognitionCtor(); - if (!Ctor) { - callbacks.onError?.("Speech recognition is not supported in this browser"); - return false; - } - - stopStt(); - - const recognition = new Ctor(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = navigator.language || "en-US"; - - recognition.addEventListener("start", () => callbacks.onStart?.()); - - recognition.addEventListener("result", (event) => { - const speechEvent = event as unknown as SpeechRecognitionEvent; - let interimTranscript = ""; - let finalTranscript = ""; - - for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) { - const result = speechEvent.results[i]; - if (!result?.[0]) { - continue; - } - const transcript = result[0].transcript; - if (result.isFinal) { - finalTranscript += transcript; - } else { - interimTranscript += transcript; - } - } - - if (finalTranscript) { - callbacks.onTranscript(finalTranscript, true); - } else if (interimTranscript) { - callbacks.onTranscript(interimTranscript, false); - } - }); - - recognition.addEventListener("error", (event) => { - const speechEvent = event as unknown as SpeechRecognitionErrorEvent; - if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") { - return; - } - callbacks.onError?.(speechEvent.error); - }); - - recognition.addEventListener("end", () => { - if (activeRecognition === recognition) { - activeRecognition = null; - } - callbacks.onEnd?.(); - }); - - activeRecognition = recognition; - recognition.start(); - return true; -} - -export function stopStt(): void { - if (activeRecognition) { - const r = activeRecognition; - activeRecognition = null; - try { - r.stop(); - } catch { - // already stopped - } - } -} - -export function isSttActive(): boolean { - return activeRecognition !== null; -} - -// ─── TTS (Text-to-Speech) ─── - -export function isTtsSupported(): boolean { - return "speechSynthesis" in globalThis; -} - -let currentUtterance: SpeechSynthesisUtterance | null = null; - -export function speakText( - text: string, - opts?: { - onStart?: () => void; - onEnd?: () => void; - onError?: (error: string) => void; - }, -): boolean { - if (!isTtsSupported()) { - opts?.onError?.("Speech synthesis is not supported in this browser"); - return false; - } - - stopTts(); - - const cleaned = stripMarkdown(text); - if (!cleaned.trim()) { - return false; - } - - const utterance = new SpeechSynthesisUtterance(cleaned); - utterance.rate = 1.0; - utterance.pitch = 1.0; - - utterance.addEventListener("start", () => opts?.onStart?.()); - utterance.addEventListener("end", () => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - opts?.onEnd?.(); - }); - utterance.addEventListener("error", (e) => { - if (currentUtterance === utterance) { - currentUtterance = null; - } - if (e.error === "canceled" || e.error === "interrupted") { - return; - } - opts?.onError?.(e.error); - }); - - currentUtterance = utterance; - speechSynthesis.speak(utterance); - return true; -} - -export function stopTts(): void { - if (currentUtterance) { - currentUtterance = null; - } - if (isTtsSupported()) { - speechSynthesis.cancel(); - } -} - -export function isTtsSpeaking(): boolean { - return isTtsSupported() && speechSynthesis.speaking; -} - -/** Strip common markdown syntax for cleaner speech output. */ -function stripMarkdown(text: string): string { - return ( - text - // code blocks - .replace(/```[\s\S]*?```/g, "") - // inline code - .replace(/`[^`]+`/g, "") - // images - .replace(/!\[.*?\]\(.*?\)/g, "") - // links → keep text - .replace(/\[([^\]]+)\]\(.*?\)/g, "$1") - // headings - .replace(/^#{1,6}\s+/gm, "") - // bold/italic - .replace(/\*{1,3}(.*?)\*{1,3}/g, "$1") - .replace(/_{1,3}(.*?)_{1,3}/g, "$1") - // blockquotes - .replace(/^>\s?/gm, "") - // horizontal rules - .replace(/^[-*_]{3,}\s*$/gm, "") - // list markers - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/^\s*\d+\.\s+/gm, "") - // HTML tags - .replace(/<[^>]+>/g, "") - // collapse whitespace - .replace(/\n{3,}/g, "\n\n") - .trim() - ); -} From 5decb00e9d2ae36c948e4cc83e42957e83108950 Mon Sep 17 00:00:00 2001 From: Neerav Makwana Date: Mon, 9 Mar 2026 21:42:54 -0400 Subject: [PATCH 305/311] fix(swiftformat): sync GatewayModels exclusions with OpenClawProtocol (#41242) Co-authored-by: Shadow --- .swiftformat | 2 +- .swiftlint.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.swiftformat b/.swiftformat index ab608a901784d..a5f551b9e3523 100644 --- a/.swiftformat +++ b/.swiftformat @@ -48,4 +48,4 @@ --allman false # Exclusions ---exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/MoltbotProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/OpenClawProtocol,apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index e4f925fdf20d3..567b1a1683aa6 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,7 +18,7 @@ excluded: - coverage - "*.playground" # Generated (protocol-gen-swift.ts) - - apps/macos/Sources/MoltbotProtocol/GatewayModels.swift + - apps/macos/Sources/OpenClawProtocol/GatewayModels.swift # Generated (generate-host-env-security-policy-swift.mjs) - apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift From 17201747579c27669ef0009c069d7cc9f9de7df0 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 9 Mar 2026 21:30:47 -0500 Subject: [PATCH 306/311] fix: auto-close no-ci PR label and document triage labels --- .github/workflows/auto-response.yml | 1 + AGENTS.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index a40149b7ccbdd..60e1707cf35b7 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -51,6 +51,7 @@ jobs: }, { label: "r: no-ci-pr", + close: true, message: "Please don't make PRs for test failures on main.\n\n" + "The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" + diff --git a/AGENTS.md b/AGENTS.md index b70210cf8e30e..1516f2e4f58d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,23 @@ - GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search - Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. +## Auto-close labels (issues and PRs) + +- If an issue/PR matches one of the reasons below, apply the label and let `.github/workflows/auto-response.yml` handle comment/close/lock. +- Do not manually close + manually comment for these reasons. +- Why: keeps wording consistent, preserves automation behavior (`state_reason`, locking), and keeps triage/reporting searchable by label. +- `r:*` labels can be used on both issues and PRs. + +- `r: skill`: close with guidance to publish skills on Clawhub. +- `r: support`: close with redirect to Discord support + stuck FAQ. +- `r: no-ci-pr`: close test-fix-only PRs for failing `main` CI and post the standard explanation. +- `r: too-many-prs`: close when author exceeds active PR limit. +- `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies. +- `r: third-party-extension`: close with guidance to ship as third-party plugin. +- `r: moltbook`: close + lock as off-topic (not affiliated). +- `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed). +- `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label). + ## Project Structure & Module Organization - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). From 25c2facc2b93432a597b98da7db5a3ebdcb6ce2a Mon Sep 17 00:00:00 2001 From: Zhe Liu <770120041@qq.com> Date: Mon, 9 Mar 2026 22:39:57 -0400 Subject: [PATCH 307/311] fix(agents): fix Brave llm-context empty snippets (#41387) Merged via squash. Prepared head SHA: 1e6f1d9d51607a115e4bf912f53149a26a5cdd82 Co-authored-by: zheliu2 <15888718+zheliu2@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/agents/tools/web-search.test.ts | 75 +++++++++++++++++++ src/agents/tools/web-search.ts | 24 +++--- .../tools/web-tools.enabled-defaults.test.ts | 2 +- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a07061ecd3..7b42bac270336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk. - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. - CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. +- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2. ## 2026.3.8 diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 4a7b002d7846f..b8bccd7dfd396 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -23,6 +23,7 @@ const { resolveKimiBaseUrl, extractKimiCitations, resolveBraveMode, + mapBraveLlmContextResults, } = __testing; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); @@ -393,3 +394,77 @@ describe("resolveBraveMode", () => { expect(resolveBraveMode({ mode: "invalid" })).toBe("web"); }); }); + +describe("mapBraveLlmContextResults", () => { + it("maps plain string snippets correctly", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [ + { + url: "https://example.com/page", + title: "Example Page", + snippets: ["first snippet", "second snippet"], + }, + ], + }, + }); + expect(results).toEqual([ + { + url: "https://example.com/page", + title: "Example Page", + snippets: ["first snippet", "second snippet"], + siteName: "example.com", + }, + ]); + }); + + it("filters out non-string and empty snippets", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [ + { + url: "https://example.com", + title: "Test", + snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[], + }, + ], + }, + }); + expect(results[0].snippets).toEqual(["valid"]); + }); + + it("handles missing snippets array", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "https://example.com", title: "No Snippets" } as never], + }, + }); + expect(results[0].snippets).toEqual([]); + }); + + it("handles empty grounding.generic", () => { + expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]); + }); + + it("handles missing grounding.generic", () => { + expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]); + }); + + it("resolves siteName from URL hostname", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }], + }, + }); + expect(results[0].siteName).toBe("docs.example.org"); + }); + + it("sets siteName to undefined for invalid URLs", () => { + const results = mapBraveLlmContextResults({ + grounding: { + generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }], + }, + }); + expect(results[0].siteName).toBeUndefined(); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 47c5a5abc9434..d4f88caea61ef 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -272,8 +272,7 @@ type BraveSearchResponse = { }; }; -type BraveLlmContextSnippet = { text: string }; -type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] }; +type BraveLlmContextResult = { url: string; title: string; snippets: string[] }; type BraveLlmContextResponse = { grounding: { generic?: BraveLlmContextResult[] }; sources?: { url?: string; hostname?: string; date?: string }[]; @@ -1429,6 +1428,18 @@ async function runKimiSearch(params: { }; } +function mapBraveLlmContextResults( + data: BraveLlmContextResponse, +): { url: string; title: string; snippets: string[]; siteName?: string }[] { + const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; + return genericResults.map((entry) => ({ + url: entry.url ?? "", + title: entry.title ?? "", + snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0), + siteName: resolveSiteName(entry.url) || undefined, + })); +} + async function runBraveLlmContextSearch(params: { query: string; apiKey: string; @@ -1477,13 +1488,7 @@ async function runBraveLlmContextSearch(params: { } const data = (await res.json()) as BraveLlmContextResponse; - const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : []; - const mapped = genericResults.map((entry) => ({ - url: entry.url ?? "", - title: entry.title ?? "", - snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean), - siteName: resolveSiteName(entry.url) || undefined, - })); + const mapped = mapBraveLlmContextResults(data); return { results: mapped, sources: data.sources }; }, @@ -2122,4 +2127,5 @@ export const __testing = { extractKimiCitations, resolveRedirectUrl: resolveCitationRedirectUrl, resolveBraveMode, + mapBraveLlmContextResults, } as const; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 54485908b8b22..80dcd6a025d11 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -694,7 +694,7 @@ describe("web_search external content wrapping", () => { const mockFetch = installBraveLlmContextFetch({ title: "Context title", url: "https://example.com/ctx", - snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }], + snippets: ["Context chunk one", "Context chunk two"], }); const tool = createWebSearchTool({ From 9432a8bb3f42f50ed7e9988388c1b120ed63a680 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:14:25 +0530 Subject: [PATCH 308/311] test: allowlist detect-secrets fixture strings --- src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index f60a127a0abe6..3500df63876d5 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -32,7 +32,7 @@ const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits"; // Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400: // https://github.com/openclaw/openclaw/issues/23440 const INSUFFICIENT_QUOTA_PAYLOAD = - '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; + '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret // Together AI error code examples: https://docs.together.ai/docs/error-codes const TOGETHER_PAYMENT_REQUIRED_MESSAGE = "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; @@ -42,7 +42,7 @@ const TOGETHER_ENGINE_OVERLOADED_MESSAGE = const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = - "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; + "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret describe("isAuthPermanentErrorMessage", () => { it("matches permanent auth failure patterns", () => { From de49a8b72c12e89170f36143ac30aaa4e938aafc Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Mon, 9 Mar 2026 23:04:35 -0400 Subject: [PATCH 309/311] Telegram: exec approvals for OpenCode/Codex (#37233) Merged via squash. Prepared head SHA: f2433790941841ade0efe6292ff4909b2edd6f18 Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/channels/telegram.md | 43 +- docs/tools/exec-approvals.md | 26 ++ extensions/telegram/src/channel.test.ts | 35 ++ extensions/telegram/src/channel.ts | 60 +++ .../bash-tools.exec-approval-followup.ts | 61 +++ src/agents/bash-tools.exec-host-gateway.ts | 146 ++++-- src/agents/bash-tools.exec-host-node.ts | 182 ++++++-- src/agents/bash-tools.exec-runtime.ts | 34 ++ src/agents/bash-tools.exec-types.ts | 15 + .../bash-tools.exec.approval-id.test.ts | 303 ++++++++++++- src/agents/pi-embedded-runner/run.ts | 3 + src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/params.ts | 3 +- .../pi-embedded-runner/run/payloads.test.ts | 9 + src/agents/pi-embedded-runner/run/payloads.ts | 36 +- src/agents/pi-embedded-runner/run/types.ts | 1 + ...pi-embedded-subscribe.handlers.messages.ts | 6 + ...ded-subscribe.handlers.tools.media.test.ts | 1 + ...-embedded-subscribe.handlers.tools.test.ts | 156 +++++++ .../pi-embedded-subscribe.handlers.tools.ts | 122 ++++- .../pi-embedded-subscribe.handlers.types.ts | 2 + src/agents/pi-embedded-subscribe.ts | 3 + src/agents/pi-embedded-subscribe.types.ts | 3 +- .../pi-tool-handler-state.test-helpers.ts | 1 + src/agents/system-prompt.ts | 3 + .../reply/agent-runner-execution.ts | 2 +- .../reply/agent-runner-utils.test.ts | 41 ++ src/auto-reply/reply/agent-runner-utils.ts | 16 +- .../agent-runner.runreplyagent.e2e.test.ts | 37 +- src/auto-reply/reply/commands-approve.ts | 38 +- src/auto-reply/reply/commands-context.ts | 1 + src/auto-reply/reply/commands.test.ts | 362 +++++---------- .../reply/dispatch-from-config.test.ts | 130 ++++++ src/auto-reply/reply/dispatch-from-config.ts | 20 + src/auto-reply/templating.ts | 2 + src/channels/plugins/outbound/telegram.ts | 1 - src/config/schema.help.quality.test.ts | 6 + src/config/schema.help.ts | 12 + src/config/schema.labels.ts | 6 + src/config/types.telegram.ts | 16 + src/config/zod-schema.providers-core.ts | 11 + src/discord/exec-approvals.ts | 23 + src/discord/monitor/exec-approvals.test.ts | 127 ++++-- src/discord/monitor/exec-approvals.ts | 41 +- src/gateway/exec-approval-manager.ts | 38 ++ .../node-invoke-system-run-approval.ts | 2 + src/gateway/server-methods/exec-approval.ts | 65 ++- .../server-methods/server-methods.test.ts | 218 +++++++-- src/gateway/server-node-events.test.ts | 17 + src/gateway/server-node-events.ts | 3 + src/infra/exec-approval-forwarder.test.ts | 132 +++++- src/infra/exec-approval-forwarder.ts | 174 ++++++-- src/infra/exec-approval-reply.ts | 172 +++++++ src/infra/exec-approval-surface.ts | 77 ++++ src/infra/outbound/deliver.test.ts | 69 +++ src/infra/outbound/deliver.ts | 20 +- src/node-host/invoke-system-run.ts | 9 +- src/node-host/invoke-types.ts | 3 + src/node-host/invoke.ts | 1 + src/telegram/approval-buttons.test.ts | 18 + src/telegram/approval-buttons.ts | 42 ++ src/telegram/bot-handlers.ts | 92 +++- src/telegram/bot-message-context.session.ts | 1 + src/telegram/bot-message-dispatch.test.ts | 45 +- src/telegram/bot-message-dispatch.ts | 16 +- .../bot-native-commands.session-meta.test.ts | 103 ++++- src/telegram/bot-native-commands.ts | 32 +- .../bot.create-telegram-bot.test-harness.ts | 5 + src/telegram/bot.test.ts | 175 +++++++- src/telegram/exec-approvals-handler.test.ts | 156 +++++++ src/telegram/exec-approvals-handler.ts | 418 ++++++++++++++++++ src/telegram/exec-approvals.test.ts | 92 ++++ src/telegram/exec-approvals.ts | 106 +++++ src/telegram/monitor.ts | 11 + src/telegram/send.test-harness.ts | 1 + src/telegram/send.test.ts | 20 + src/telegram/send.ts | 100 ++++- 78 files changed, 4058 insertions(+), 524 deletions(-) create mode 100644 src/agents/bash-tools.exec-approval-followup.ts create mode 100644 src/discord/exec-approvals.ts create mode 100644 src/infra/exec-approval-reply.ts create mode 100644 src/infra/exec-approval-surface.ts create mode 100644 src/telegram/approval-buttons.test.ts create mode 100644 src/telegram/approval-buttons.ts create mode 100644 src/telegram/exec-approvals-handler.test.ts create mode 100644 src/telegram/exec-approvals-handler.ts create mode 100644 src/telegram/exec-approvals.test.ts create mode 100644 src/telegram/exec-approvals.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b42bac270336..1e5273a8df52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu. - CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth. - Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2. +- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. ## 2026.3.8 diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f49ea5fe3f76b..a039cb43483ab 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -760,6 +760,34 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ - `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled + + + Telegram supports exec approvals in approver DMs and can optionally post approval prompts in the originating chat or topic. + + Config path: + + - `channels.telegram.execApprovals.enabled` + - `channels.telegram.execApprovals.approvers` + - `channels.telegram.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`) + - `agentFilter`, `sessionFilter` + + Approvers must be numeric Telegram user IDs. When `enabled` is false or `approvers` is empty, Telegram does not act as an exec approval client. Approval requests fall back to other configured approval routes or the exec approval fallback policy. + + Delivery rules: + + - `target: "dm"` sends approval prompts only to configured approver DMs + - `target: "channel"` sends the prompt back to the originating Telegram chat/topic + - `target: "both"` sends to approver DMs and the originating chat/topic + + Only configured approvers can approve or deny. Non-approvers cannot use `/approve` and cannot use Telegram approval buttons. + + Channel delivery shows the command text in the chat, so only enable `channel` or `both` in trusted groups/topics. When the prompt lands in a forum topic, OpenClaw preserves the topic for both the approval prompt and the post-approval follow-up. + + Inline approval buttons also depend on `channels.telegram.capabilities.inlineButtons` allowing the target surface (`dm`, `group`, or `all`). + + Related docs: [Exec approvals](/tools/exec-approvals) + + ## Troubleshooting @@ -859,10 +887,16 @@ Primary reference: - `channels.telegram.groups..enabled`: disable the group when `false`. - `channels.telegram.groups..topics..*`: per-topic overrides (group fields + topic-only `agentId`). - `channels.telegram.groups..topics..agentId`: route this topic to a specific agent (overrides group-level and binding routing). - - `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). - - `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. - - top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)). - - `channels.telegram.direct..topics..agentId`: route DM topics to a specific agent (same behavior as forum topics). +- `channels.telegram.groups..topics..groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`). +- `channels.telegram.groups..topics..requireMention`: per-topic mention gating override. +- top-level `bindings[]` with `type: "acp"` and canonical topic id `chatId:topic:topicId` in `match.peer.id`: persistent ACP topic binding fields (see [ACP Agents](/tools/acp-agents#channel-specific-settings)). +- `channels.telegram.direct..topics..agentId`: route DM topics to a specific agent (same behavior as forum topics). +- `channels.telegram.execApprovals.enabled`: enable Telegram as a chat-based exec approval client for this account. +- `channels.telegram.execApprovals.approvers`: Telegram user IDs allowed to approve or deny exec requests. Required when exec approvals are enabled. +- `channels.telegram.execApprovals.target`: `dm | channel | both` (default: `dm`). `channel` and `both` preserve the originating Telegram topic when present. +- `channels.telegram.execApprovals.agentFilter`: optional agent ID filter for forwarded approval prompts. +- `channels.telegram.execApprovals.sessionFilter`: optional session key filter (substring or regex) for forwarded approval prompts. +- `channels.telegram.accounts..execApprovals`: per-account override for Telegram exec approval routing and approver authorization. - `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist). - `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. - `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands. @@ -894,6 +928,7 @@ Telegram-specific high-signal fields: - startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` - access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`) +- exec approvals: `execApprovals`, `accounts.*.execApprovals` - command/menu: `commands.native`, `commands.nativeSkills`, `customCommands` - threading/replies: `replyToMode` - streaming: `streaming` (preview), `blockStreaming` diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index d538e4110936a..91fdff80650c5 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -309,6 +309,32 @@ Reply in chat: /approve deny ``` +### Built-in chat approval clients + +Discord and Telegram can also act as explicit exec approval clients with channel-specific config. + +- Discord: `channels.discord.execApprovals.*` +- Telegram: `channels.telegram.execApprovals.*` + +These clients are opt-in. If a channel does not have exec approvals enabled, OpenClaw does not treat +that channel as an approval surface just because the conversation happened there. + +Shared behavior: + +- only configured approvers can approve or deny +- the requester does not need to be an approver +- when channel delivery is enabled, approval prompts include the command text +- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback` + +Telegram defaults to approver DMs (`target: "dm"`). You can switch to `channel` or `both` when you +want approval prompts to appear in the originating Telegram chat/topic as well. For Telegram forum +topics, OpenClaw preserves the topic for the approval prompt and the post-approval follow-up. + +See: + +- [Discord](/channels/discord#exec-approvals-in-discord) +- [Telegram](/channels/telegram#exec-approvals-in-telegram) + ### macOS IPC flow ``` diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 1f40a5f1cceb7..c1912db56f055 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -179,6 +179,41 @@ describe("telegramPlugin duplicate token guard", () => { expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" }); }); + it("preserves buttons for outbound text payload sends", async () => { + const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" })); + setTelegramRuntime({ + channel: { + telegram: { + sendMessageTelegram, + }, + }, + } as unknown as PluginRuntime); + + const result = await telegramPlugin.outbound!.sendPayload!({ + cfg: createCfg(), + to: "12345", + text: "", + payload: { + text: "Approval required", + channelData: { + telegram: { + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }, + }, + }, + accountId: "ops", + }); + + expect(sendMessageTelegram).toHaveBeenCalledWith( + "12345", + "Approval required", + expect.objectContaining({ + buttons: [[{ text: "Allow Once", callback_data: "/approve abc allow-once" }]], + }), + ); + expect(result).toMatchObject({ channel: "telegram", messageId: "tg-2" }); + }); + it("ignores accounts with missing tokens during duplicate-token checks", async () => { const cfg = createCfg(); cfg.channels!.telegram!.accounts!.ops = {} as never; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0f4721a4d621d..7ea0a7a6525d3 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -91,6 +91,10 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; +type TelegramInlineButtons = ReadonlyArray< + ReadonlyArray<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> +>; + const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, @@ -317,6 +321,62 @@ export const telegramPlugin: ChannelPlugin { + const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + const replyToMessageId = parseTelegramReplyToMessageId(replyToId); + const messageThreadId = parseTelegramThreadId(threadId); + const telegramData = payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons; quoteText?: string } + | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; + const text = payload.text ?? ""; + const mediaUrls = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + const baseOpts = { + verbose: false, + cfg, + mediaLocalRoots, + messageThreadId, + replyToMessageId, + quoteText, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }; + + if (mediaUrls.length === 0) { + const result = await send(to, text, { + ...baseOpts, + buttons: telegramData?.buttons, + }); + return { channel: "telegram", ...result }; + } + + let finalResult: Awaited> | undefined; + for (let i = 0; i < mediaUrls.length; i += 1) { + const mediaUrl = mediaUrls[i]; + const isFirst = i === 0; + finalResult = await send(to, isFirst ? text : "", { + ...baseOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }); + } + return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; + }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseTelegramReplyToMessageId(replyToId); diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts new file mode 100644 index 0000000000000..af24f07fb500a --- /dev/null +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -0,0 +1,61 @@ +import { callGatewayTool } from "./tools/gateway.js"; + +type ExecApprovalFollowupParams = { + approvalId: string; + sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; + resultText: string; +}; + +export function buildExecApprovalFollowupPrompt(resultText: string): string { + return [ + "An async command the user already approved has completed.", + "Do not run the command again.", + "", + "Exact completion details:", + resultText.trim(), + "", + "Reply to the user in a helpful way.", + "If it succeeded, share the relevant output.", + "If it failed, explain what went wrong.", + ].join("\n"); +} + +export async function sendExecApprovalFollowup( + params: ExecApprovalFollowupParams, +): Promise { + const sessionKey = params.sessionKey?.trim(); + const resultText = params.resultText.trim(); + if (!sessionKey || !resultText) { + return false; + } + + const channel = params.turnSourceChannel?.trim(); + const to = params.turnSourceTo?.trim(); + const threadId = + params.turnSourceThreadId != null && params.turnSourceThreadId !== "" + ? String(params.turnSourceThreadId) + : undefined; + + await callGatewayTool( + "agent", + { timeoutMs: 60_000 }, + { + sessionKey, + message: buildExecApprovalFollowupPrompt(resultText), + deliver: true, + bestEffortDeliver: true, + channel: channel && to ? channel : undefined, + to: channel && to ? to : undefined, + accountId: channel && to ? params.turnSourceAccountId?.trim() || undefined : undefined, + threadId: channel && to ? threadId : undefined, + idempotencyKey: `exec-approval-followup:${params.approvalId}`, + }, + { expectFinal: true }, + ); + + return true; +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 49a958c9c5b0a..6b43fbe8663db 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { loadConfig } from "../config/config.js"; +import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; +import { + hasConfiguredExecApprovalDmRoute, + resolveExecApprovalInitiatingSurfaceState, +} from "../infra/exec-approval-surface.js"; import { addAllowlistEntry, type ExecAsk, @@ -13,6 +19,7 @@ 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 { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, @@ -25,9 +32,9 @@ import { resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; import { + buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, - emitExecSystemEvent, normalizeNotifyOutput, runExecProcess, } from "./bash-tools.exec-runtime.js"; @@ -141,8 +148,6 @@ export async function processGatewayAllowlist( const { approvalId, approvalSlug, - contextKey, - noticeSeconds, warningText, expiresAtMs: defaultExpiresAtMs, preResolvedDecision: defaultPreResolvedDecision, @@ -174,19 +179,37 @@ export async function processGatewayAllowlist( }); expiresAtMs = registration.expiresAtMs; preResolvedDecision = registration.finalDecision; + const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({ + channel: params.turnSourceChannel, + accountId: params.turnSourceAccountId, + }); + const cfg = loadConfig(); + const sentApproverDms = + (initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") && + hasConfiguredExecApprovalDmRoute(cfg); + const unavailableReason = + preResolvedDecision === null + ? "no-approval-route" + : initiatingSurface.kind === "disabled" + ? "initiating-platform-disabled" + : initiatingSurface.kind === "unsupported" + ? "initiating-platform-unsupported" + : null; void (async () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ), + void sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, + }), }); if (decision === undefined) { return; @@ -230,13 +253,15 @@ export async function processGatewayAllowlist( } if (deniedReason) { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, + }).catch(() => {}); return; } @@ -262,32 +287,21 @@ export async function processGatewayAllowlist( timeoutSec: effectiveTimeout, }); } catch { - emitExecSystemEvent( - `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, + }).catch(() => {}); return; } markBackgrounded(run.session); - let runningTimer: NodeJS.Timeout | null = null; - if (params.approvalRunningNoticeMs > 0) { - runningTimer = setTimeout(() => { - emitExecSystemEvent( - `Exec running (gateway id=${approvalId}, session=${run?.session.id}, >${noticeSeconds}s): ${params.command}`, - { sessionKey: params.notifySessionKey, contextKey }, - ); - }, params.approvalRunningNoticeMs); - } - const outcome = await run.promise; - if (runningTimer) { - clearTimeout(runningTimer); - } const output = normalizeNotifyOutput( tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), ); @@ -295,7 +309,15 @@ export async function processGatewayAllowlist( const summary = output ? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}` : `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`; - emitExecSystemEvent(summary, { sessionKey: params.notifySessionKey, contextKey }); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: summary, + }).catch(() => {}); })(); return { @@ -304,19 +326,45 @@ export async function processGatewayAllowlist( { type: "text", text: - `${warningText}Approval required (id ${approvalSlug}). ` + - "Approve to run; updates will arrive after completion.", + unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText, + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + }).text ?? "") + : buildApprovalPendingMessage({ + warningText, + approvalSlug, + approvalId, + command: params.command, + cwd: params.workdir, + host: "gateway", + }), }, ], - details: { - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "gateway", - command: params.command, - cwd: params.workdir, - }, + details: + unavailableReason !== null + ? ({ + status: "approval-unavailable", + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + host: "gateway", + command: params.command, + cwd: params.workdir, + warningText, + } satisfies ExecToolDetails) + : ({ + status: "approval-pending", + approvalId, + approvalSlug, + expiresAtMs, + host: "gateway", + command: params.command, + cwd: params.workdir, + warningText, + } satisfies ExecToolDetails), }, }; } diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index b66a6ededf1e0..97eb421803526 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -1,5 +1,11 @@ import crypto from "node:crypto"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { loadConfig } from "../config/config.js"; +import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js"; +import { + hasConfiguredExecApprovalDmRoute, + resolveExecApprovalInitiatingSurfaceState, +} from "../infra/exec-approval-surface.js"; import { type ExecApprovalsFile, type ExecAsk, @@ -12,6 +18,7 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { parsePreparedSystemRunPayload } from "../infra/system-run-approval-context.js"; import { logInfo } from "../logger.js"; +import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, @@ -23,7 +30,12 @@ import { resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, } from "./bash-tools.exec-host-shared.js"; -import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js"; +import { + buildApprovalPendingMessage, + DEFAULT_NOTIFY_TAIL_CHARS, + createApprovalSlug, + normalizeNotifyOutput, +} from "./bash-tools.exec-runtime.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; @@ -187,6 +199,7 @@ export async function executeNodeHostCommand( approvedByAsk: boolean, approvalDecision: "allow-once" | "allow-always" | null, runId?: string, + suppressNotifyOnExit?: boolean, ) => ({ nodeId, @@ -202,6 +215,7 @@ export async function executeNodeHostCommand( approved: approvedByAsk, approvalDecision: approvalDecision ?? undefined, runId: runId ?? undefined, + suppressNotifyOnExit: suppressNotifyOnExit === true ? true : undefined, }, idempotencyKey: crypto.randomUUID(), }) satisfies Record; @@ -210,8 +224,6 @@ export async function executeNodeHostCommand( const { approvalId, approvalSlug, - contextKey, - noticeSeconds, warningText, expiresAtMs: defaultExpiresAtMs, preResolvedDecision: defaultPreResolvedDecision, @@ -243,16 +255,37 @@ export async function executeNodeHostCommand( }); expiresAtMs = registration.expiresAtMs; preResolvedDecision = registration.finalDecision; + const initiatingSurface = resolveExecApprovalInitiatingSurfaceState({ + channel: params.turnSourceChannel, + accountId: params.turnSourceAccountId, + }); + const cfg = loadConfig(); + const sentApproverDms = + (initiatingSurface.kind === "disabled" || initiatingSurface.kind === "unsupported") && + hasConfiguredExecApprovalDmRoute(cfg); + const unavailableReason = + preResolvedDecision === null + ? "no-approval-route" + : initiatingSurface.kind === "disabled" + ? "initiating-platform-disabled" + : initiatingSurface.kind === "unsupported" + ? "initiating-platform-unsupported" + : null; void (async () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, - { sessionKey: params.notifySessionKey, contextKey }, - ), + void sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, + }), }); if (decision === undefined) { return; @@ -278,44 +311,67 @@ export async function executeNodeHostCommand( } if (deniedReason) { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (node=${nodeId} id=${approvalId}, ${deniedReason}): ${params.command}`, + }).catch(() => {}); return; } - let runningTimer: NodeJS.Timeout | null = null; - if (params.approvalRunningNoticeMs > 0) { - runningTimer = setTimeout(() => { - emitExecSystemEvent( - `Exec running (node=${nodeId} id=${approvalId}, >${noticeSeconds}s): ${params.command}`, - { sessionKey: params.notifySessionKey, contextKey }, - ); - }, params.approvalRunningNoticeMs); - } - try { - await callGatewayTool( + const raw = await callGatewayTool<{ + payload?: { + stdout?: string; + stderr?: string; + error?: string | null; + exitCode?: number | null; + timedOut?: boolean; + }; + }>( "node.invoke", { timeoutMs: invokeTimeoutMs }, - buildInvokeParams(approvedByAsk, approvalDecision, approvalId), + buildInvokeParams(approvedByAsk, approvalDecision, approvalId, true), ); + const payload = + raw?.payload && typeof raw.payload === "object" + ? (raw.payload as { + stdout?: string; + stderr?: string; + error?: string | null; + exitCode?: number | null; + timedOut?: boolean; + }) + : {}; + const combined = [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n"); + const output = normalizeNotifyOutput(combined.slice(-DEFAULT_NOTIFY_TAIL_CHARS)); + const exitLabel = payload.timedOut ? "timeout" : `code ${payload.exitCode ?? "?"}`; + const summary = output + ? `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})\n${output}` + : `Exec finished (node=${nodeId} id=${approvalId}, ${exitLabel})`; + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: summary, + }).catch(() => {}); } catch { - emitExecSystemEvent( - `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, - { - sessionKey: params.notifySessionKey, - contextKey, - }, - ); - } finally { - if (runningTimer) { - clearTimeout(runningTimer); - } + await sendExecApprovalFollowup({ + approvalId, + sessionKey: params.notifySessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + resultText: `Exec denied (node=${nodeId} id=${approvalId}, invoke-failed): ${params.command}`, + }).catch(() => {}); } })(); @@ -324,20 +380,48 @@ export async function executeNodeHostCommand( { type: "text", text: - `${warningText}Approval required (id ${approvalSlug}). ` + - "Approve to run; updates will arrive after completion.", + unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText, + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + }).text ?? "") + : buildApprovalPendingMessage({ + warningText, + approvalSlug, + approvalId, + command: prepared.cmdText, + cwd: runCwd, + host: "node", + nodeId, + }), }, ], - details: { - status: "approval-pending", - approvalId, - approvalSlug, - expiresAtMs, - host: "node", - command: params.command, - cwd: params.workdir, - nodeId, - }, + details: + unavailableReason !== null + ? ({ + status: "approval-unavailable", + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + sentApproverDms, + host: "node", + command: params.command, + cwd: params.workdir, + nodeId, + warningText, + } satisfies ExecToolDetails) + : ({ + status: "approval-pending", + approvalId, + approvalSlug, + expiresAtMs, + host: "node", + command: params.command, + cwd: params.workdir, + nodeId, + warningText, + } satisfies ExecToolDetails), }; } diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 9714e4255ee14..5c3301414b950 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -230,6 +230,40 @@ export function createApprovalSlug(id: string) { return id.slice(0, APPROVAL_SLUG_LENGTH); } +export function buildApprovalPendingMessage(params: { + warningText?: string; + approvalSlug: string; + approvalId: string; + command: string; + cwd: string; + host: "gateway" | "node"; + nodeId?: string; +}) { + let fence = "```"; + while (params.command.includes(fence)) { + fence += "`"; + } + const commandBlock = `${fence}sh\n${params.command}\n${fence}`; + const lines: string[] = []; + const warningText = params.warningText?.trim(); + if (warningText) { + lines.push(warningText, ""); + } + lines.push(`Approval required (id ${params.approvalSlug}, full ${params.approvalId}).`); + lines.push(`Host: ${params.host}`); + if (params.nodeId) { + lines.push(`Node: ${params.nodeId}`); + } + lines.push(`CWD: ${params.cwd}`); + lines.push("Command:"); + lines.push(commandBlock); + lines.push("Mode: foreground (interactive approvals available)."); + lines.push("Background mode requires pre-approved policy (allow-always or ask=off)."); + lines.push(`Reply with: /approve ${params.approvalSlug} allow-once|allow-always|deny`); + lines.push("If the short code is ambiguous, use the full id in /approve."); + return lines.join("\n"); +} + export function resolveApprovalRunningNoticeMs(value?: number) { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_APPROVAL_RUNNING_NOTICE_MS; diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index bef8ea4bff135..7236fdaaf47b7 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -60,4 +60,19 @@ export type ExecToolDetails = command: string; cwd?: string; nodeId?: string; + warningText?: string; + } + | { + status: "approval-unavailable"; + reason: + | "initiating-platform-disabled" + | "initiating-platform-unsupported" + | "no-approval-route"; + channelLabel?: string; + sentApproverDms?: boolean; + host: ExecHost; + command: string; + cwd?: string; + nodeId?: string; + warningText?: string; }; diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index b7f4729948cec..cc94f83d66543 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearConfigCache } from "../config/config.js"; import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js"; vi.mock("./tools/gateway.js", () => ({ @@ -63,6 +64,7 @@ describe("exec approvals", () => { afterEach(() => { vi.resetAllMocks(); + clearConfigCache(); if (previousHome === undefined) { delete process.env.HOME; } else { @@ -77,6 +79,7 @@ describe("exec approvals", () => { it("reuses approval id as the node runId", async () => { let invokeParams: unknown; + let agentParams: unknown; vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "exec.approval.request") { @@ -85,6 +88,10 @@ describe("exec approvals", () => { if (method === "exec.approval.waitDecision") { return { decision: "allow-once" }; } + if (method === "agent") { + agentParams = params; + return { status: "ok" }; + } if (method === "node.invoke") { const invoke = params as { command?: string }; if (invoke.command === "system.run.prepare") { @@ -102,11 +109,24 @@ describe("exec approvals", () => { host: "node", ask: "always", approvalRunningNoticeMs: 0, + sessionKey: "agent:main:main", }); const result = await tool.execute("call1", { command: "ls -la" }); expect(result.details.status).toBe("approval-pending"); - const approvalId = (result.details as { approvalId: string }).approvalId; + const details = result.details as { approvalId: string; approvalSlug: string }; + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + `Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`, + ); + expect(pendingText).toContain(`full ${details.approvalId}`); + expect(pendingText).toContain("Host: node"); + expect(pendingText).toContain("Node: node-1"); + expect(pendingText).toContain(`CWD: ${process.cwd()}`); + expect(pendingText).toContain("Command:\n```sh\nls -la\n```"); + expect(pendingText).toContain("Mode: foreground (interactive approvals available)."); + expect(pendingText).toContain("Background mode requires pre-approved policy"); + const approvalId = details.approvalId; await expect .poll(() => (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId, { @@ -114,6 +134,12 @@ describe("exec approvals", () => { interval: 20, }) .toBe(approvalId); + expect( + (invokeParams as { params?: { suppressNotifyOnExit?: boolean } } | undefined)?.params, + ).toMatchObject({ + suppressNotifyOnExit: true, + }); + await expect.poll(() => agentParams, { timeout: 2_000, interval: 20 }).toBeTruthy(); }); it("skips approval when node allowlist is satisfied", async () => { @@ -287,11 +313,181 @@ describe("exec approvals", () => { const result = await tool.execute("call4", { command: "echo ok", elevated: true }); expect(result.details.status).toBe("approval-pending"); + const details = result.details as { approvalId: string; approvalSlug: string }; + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + `Reply with: /approve ${details.approvalSlug} allow-once|allow-always|deny`, + ); + expect(pendingText).toContain(`full ${details.approvalId}`); + expect(pendingText).toContain("Host: gateway"); + expect(pendingText).toContain(`CWD: ${process.cwd()}`); + expect(pendingText).toContain("Command:\n```sh\necho ok\n```"); await approvalSeen; expect(calls).toContain("exec.approval.request"); expect(calls).toContain("exec.approval.waitDecision"); }); + it("starts a direct agent follow-up after approved gateway exec completes", async () => { + const agentCalls: Array> = []; + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + if (method === "exec.approval.request") { + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { + return { decision: "allow-once" }; + } + if (method === "agent") { + agentCalls.push(params as Record); + return { status: "ok" }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "always", + approvalRunningNoticeMs: 0, + sessionKey: "agent:main:main", + elevated: { enabled: true, allowed: true, defaultLevel: "ask" }, + }); + + const result = await tool.execute("call-gw-followup", { + command: "echo ok", + workdir: process.cwd(), + gatewayUrl: undefined, + gatewayToken: undefined, + }); + + expect(result.details.status).toBe("approval-pending"); + await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 20 }).toBe(1); + expect(agentCalls[0]).toEqual( + expect.objectContaining({ + sessionKey: "agent:main:main", + deliver: true, + idempotencyKey: expect.stringContaining("exec-approval-followup:"), + }), + ); + expect(typeof agentCalls[0]?.message).toBe("string"); + expect(agentCalls[0]?.message).toContain( + "An async command the user already approved has completed.", + ); + }); + + it("requires a separate approval for each elevated command after allow-once", async () => { + const requestCommands: string[] = []; + const requestIds: string[] = []; + const waitIds: string[] = []; + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + if (method === "exec.approval.request") { + const request = params as { id?: string; command?: string }; + if (typeof request.command === "string") { + requestCommands.push(request.command); + } + if (typeof request.id === "string") { + requestIds.push(request.id); + } + return { status: "accepted", id: request.id }; + } + if (method === "exec.approval.waitDecision") { + const wait = params as { id?: string }; + if (typeof wait.id === "string") { + waitIds.push(wait.id); + } + return { decision: "allow-once" }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + elevated: { enabled: true, allowed: true, defaultLevel: "ask" }, + }); + + const first = await tool.execute("call-seq-1", { + command: "npm view diver --json", + elevated: true, + }); + const second = await tool.execute("call-seq-2", { + command: "brew outdated", + elevated: true, + }); + + expect(first.details.status).toBe("approval-pending"); + expect(second.details.status).toBe("approval-pending"); + expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]); + expect(requestIds).toHaveLength(2); + expect(requestIds[0]).not.toBe(requestIds[1]); + expect(waitIds).toEqual(requestIds); + }); + + it("shows full chained gateway commands in approval-pending message", async () => { + const calls: string[] = []; + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "exec.approval.request") { + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { + return { decision: "deny" }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + const result = await tool.execute("call-chain-gateway", { + command: "npm view diver --json | jq .name && brew outdated", + }); + + expect(result.details.status).toBe("approval-pending"); + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + "Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```", + ); + expect(calls).toContain("exec.approval.request"); + }); + + it("shows full chained node commands in approval-pending message", async () => { + const calls: string[] = []; + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "node.invoke") { + const invoke = params as { command?: string }; + if (invoke.command === "system.run.prepare") { + return buildPreparedSystemRunPayload(params); + } + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "node", + ask: "always", + security: "full", + approvalRunningNoticeMs: 0, + }); + + const result = await tool.execute("call-chain-node", { + command: "npm view diver --json | jq .name && brew outdated", + }); + + expect(result.details.status).toBe("approval-pending"); + const pendingText = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(pendingText).toContain( + "Command:\n```sh\nnpm view diver --json | jq .name && brew outdated\n```", + ); + expect(calls).toContain("exec.approval.request"); + }); + it("waits for approval registration before returning approval-pending", async () => { const calls: string[] = []; let resolveRegistration: ((value: unknown) => void) | undefined; @@ -354,6 +550,111 @@ describe("exec approvals", () => { ); }); + it("returns an unavailable approval message instead of a local /approve prompt when discord exec approvals are disabled", async () => { + const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + channels: { + discord: { + enabled: true, + execApprovals: { enabled: false }, + }, + }, + }), + ); + + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { + return { decision: null }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "always", + approvalRunningNoticeMs: 0, + messageProvider: "discord", + accountId: "default", + currentChannelId: "1234567890", + }); + + const result = await tool.execute("call-unavailable", { + command: "npm view diver name version description", + }); + + expect(result.details.status).toBe("approval-unavailable"); + const text = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(text).toContain("chat exec approvals are not enabled on Discord"); + expect(text).toContain("Web UI or terminal UI"); + expect(text).not.toContain("/approve"); + expect(text).not.toContain("npm view diver name version description"); + expect(text).not.toContain("Pending command:"); + expect(text).not.toContain("Host:"); + expect(text).not.toContain("CWD:"); + }); + + it("tells Telegram users that allowed approvers were DMed when Telegram approvals are disabled but Discord DM approvals are enabled", async () => { + const configPath = path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + channels: { + telegram: { + enabled: true, + execApprovals: { enabled: false }, + }, + discord: { + enabled: true, + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + }, + null, + 2, + ), + ); + + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { + return { decision: null }; + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "always", + approvalRunningNoticeMs: 0, + messageProvider: "telegram", + accountId: "default", + currentChannelId: "-1003841603622", + }); + + const result = await tool.execute("call-tg-unavailable", { + command: "npm view diver name version description", + }); + + expect(result.details.status).toBe("approval-unavailable"); + const text = result.content.find((part) => part.type === "text")?.text ?? ""; + expect(text).toContain("Approval required. I sent the allowed approvers DMs."); + expect(text).not.toContain("/approve"); + expect(text).not.toContain("npm view diver name version description"); + expect(text).not.toContain("Pending command:"); + expect(text).not.toContain("Host:"); + expect(text).not.toContain("CWD:"); + }); + it("denies node obfuscated command when approval request times out", async () => { vi.mocked(detectCommandObfuscation).mockReturnValue({ detected: true, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 381c76ada1846..298bac9fe9e8b 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1457,6 +1457,7 @@ export async function runEmbeddedPiAgent( suppressToolErrorWarnings: params.suppressToolErrorWarnings, inlineToolResultsAllowed: false, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, }); // Timeout aborts can leave the run without any assistant payloads. @@ -1479,6 +1480,7 @@ export async function runEmbeddedPiAgent( systemPromptReport: attempt.systemPromptReport, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, messagingToolSentTargets: attempt.messagingToolSentTargets, @@ -1526,6 +1528,7 @@ export async function runEmbeddedPiAgent( : undefined, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, messagingToolSentTexts: attempt.messagingToolSentTexts, messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, messagingToolSentTargets: attempt.messagingToolSentTargets, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d7fa541c2becd..25f13c666c7d4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1544,6 +1544,7 @@ export async function runEmbeddedAttempt( getMessagingToolSentTargets, getSuccessfulCronAdds, didSendViaMessagingTool, + didSendDeterministicApprovalPrompt, getLastToolError, getUsageTotals, getCompactionCount, @@ -2058,6 +2059,7 @@ export async function runEmbeddedAttempt( lastAssistant, lastToolError: getLastToolError?.(), didSendViaMessagingTool: didSendViaMessagingTool(), + didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPrompt(), messagingToolSentTexts: getMessagingToolSentTexts(), messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), messagingToolSentTargets: getMessagingToolSentTargets(), diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 6d067c910bf61..ee743d7a0c17c 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -1,5 +1,6 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; +import type { ReplyPayload } from "../../../auto-reply/types.js"; import type { AgentStreamParams } from "../../../commands/agent/types.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { enqueueCommand } from "../../../process/command-queue.js"; @@ -104,7 +105,7 @@ export type RunEmbeddedPiAgentParams = { blockReplyChunking?: BlockReplyChunking; onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; onReasoningEnd?: () => void | Promise; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; + onToolResult?: (payload: ReplyPayload) => void | Promise; onAgentEvent?: (evt: { stream: string; data: Record }) => void; lane?: string; enqueue?: typeof enqueueCommand; diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index ee8acd1d43e6e..6c81fb121503a 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -82,4 +82,13 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { expect(payloads).toHaveLength(0); }); + + it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => { + const payloads = buildPayloads({ + assistantTexts: ["Approval is needed. Please run /approve abc allow-once"], + didSendDeterministicApprovalPrompt: true, + }); + + expect(payloads).toHaveLength(0); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index c3c8784545130..16a78ec2e9702 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -102,6 +102,7 @@ export function buildEmbeddedRunPayloads(params: { suppressToolErrorWarnings?: boolean; inlineToolResultsAllowed: boolean; didSendViaMessagingTool?: boolean; + didSendDeterministicApprovalPrompt?: boolean; }): Array<{ text?: string; mediaUrl?: string; @@ -125,14 +126,17 @@ export function buildEmbeddedRunPayloads(params: { }> = []; const useMarkdown = params.toolResultFormat === "markdown"; + const suppressAssistantArtifacts = params.didSendDeterministicApprovalPrompt === true; const lastAssistantErrored = params.lastAssistant?.stopReason === "error"; const errorText = params.lastAssistant - ? formatAssistantErrorText(params.lastAssistant, { - cfg: params.config, - sessionKey: params.sessionKey, - provider: params.provider, - model: params.model, - }) + ? suppressAssistantArtifacts + ? undefined + : formatAssistantErrorText(params.lastAssistant, { + cfg: params.config, + sessionKey: params.sessionKey, + provider: params.provider, + model: params.model, + }) : undefined; const rawErrorMessage = lastAssistantErrored ? params.lastAssistant?.errorMessage?.trim() || undefined @@ -184,8 +188,9 @@ export function buildEmbeddedRunPayloads(params: { } } - const reasoningText = - params.lastAssistant && params.reasoningLevel === "on" + const reasoningText = suppressAssistantArtifacts + ? "" + : params.lastAssistant && params.reasoningLevel === "on" ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { @@ -243,13 +248,14 @@ export function buildEmbeddedRunPayloads(params: { } return isRawApiErrorPayload(trimmed); }; - const answerTexts = ( - params.assistantTexts.length - ? params.assistantTexts - : fallbackAnswerText - ? [fallbackAnswerText] - : [] - ).filter((text) => !shouldSuppressRawErrorText(text)); + const answerTexts = suppressAssistantArtifacts + ? [] + : (params.assistantTexts.length + ? params.assistantTexts + : fallbackAnswerText + ? [fallbackAnswerText] + : [] + ).filter((text) => !shouldSuppressRawErrorText(text)); let hasUserFacingAssistantReply = false; for (const text of answerTexts) { diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index dff5aa6f2511b..7e6ad0578f140 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -54,6 +54,7 @@ export type EmbeddedRunAttemptResult = { actionFingerprint?: string; }; didSendViaMessagingTool: boolean; + didSendDeterministicApprovalPrompt?: boolean; messagingToolSentTexts: string[]; messagingToolSentMediaUrls: string[]; messagingToolSentTargets: MessagingToolSend[]; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index c89a4b71496c6..04f47e67cdef7 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -85,6 +85,9 @@ export function handleMessageUpdate( } ctx.noteLastAssistant(msg); + if (ctx.state.deterministicApprovalPromptSent) { + return; + } const assistantEvent = evt.assistantMessageEvent; const assistantRecord = @@ -261,6 +264,9 @@ export function handleMessageEnd( const assistantMessage = msg; ctx.noteLastAssistant(assistantMessage); ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage); + if (ctx.state.deterministicApprovalPromptSent) { + return; + } promoteThinkingTagsToBlocks(assistantMessage); const rawText = extractAssistantText(assistantMessage); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts index 741fa96c8159f..66685f04036de 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.media.test.ts @@ -28,6 +28,7 @@ function createMockContext(overrides?: { messagingToolSentTextsNormalized: [], messagingToolSentMediaUrls: [], messagingToolSentTargets: [], + deterministicApprovalPromptSent: false, }, log: { debug: vi.fn(), warn: vi.fn() }, shouldEmitToolResult: vi.fn(() => false), diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index 96a988e5bc61d..3cf7935a8a2a8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -45,6 +45,7 @@ function createTestContext(): { messagingToolSentMediaUrls: [], messagingToolSentTargets: [], successfulCronAdds: 0, + deterministicApprovalPromptSent: false, }, shouldEmitToolResult: () => false, shouldEmitToolOutput: () => false, @@ -175,6 +176,161 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => { }); }); +describe("handleToolExecutionEnd exec approval prompts", () => { + it("emits a deterministic approval payload and marks assistant output suppressed", async () => { + const { ctx } = createTestContext(); + const onToolResult = vi.fn(); + ctx.params.onToolResult = onToolResult; + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-approval", + isError: false, + result: { + details: { + status: "approval-pending", + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + expiresAtMs: 1_800_000_000_000, + host: "gateway", + command: "npm view diver name version description", + cwd: "/tmp/work", + warningText: "Warning: heredoc execution requires explicit approval in allowlist mode.", + }, + }, + } as never, + ); + + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"), + channelData: { + execApproval: { + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }), + ); + expect(ctx.state.deterministicApprovalPromptSent).toBe(true); + }); + + it("emits a deterministic unavailable payload when the initiating surface cannot approve", async () => { + const { ctx } = createTestContext(); + const onToolResult = vi.fn(); + ctx.params.onToolResult = onToolResult; + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-unavailable", + isError: false, + result: { + details: { + status: "approval-unavailable", + reason: "initiating-platform-disabled", + channelLabel: "Discord", + }, + }, + } as never, + ); + + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("chat exec approvals are not enabled on Discord"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("/approve"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("Pending command:"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("Host:"), + }), + ); + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.not.stringContaining("CWD:"), + }), + ); + expect(ctx.state.deterministicApprovalPromptSent).toBe(true); + }); + + it("emits the shared approver-DM notice when another approval client received the request", async () => { + const { ctx } = createTestContext(); + const onToolResult = vi.fn(); + ctx.params.onToolResult = onToolResult; + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-unavailable-dm-redirect", + isError: false, + result: { + details: { + status: "approval-unavailable", + reason: "initiating-platform-disabled", + channelLabel: "Telegram", + sentApproverDms: true, + }, + }, + } as never, + ); + + expect(onToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Approval required. I sent the allowed approvers DMs.", + }), + ); + expect(ctx.state.deterministicApprovalPromptSent).toBe(true); + }); + + it("does not suppress assistant output when deterministic prompt delivery rejects", async () => { + const { ctx } = createTestContext(); + ctx.params.onToolResult = vi.fn(async () => { + throw new Error("delivery failed"); + }); + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "exec", + toolCallId: "tool-exec-approval-reject", + isError: false, + result: { + details: { + status: "approval-pending", + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + expiresAtMs: 1_800_000_000_000, + host: "gateway", + command: "npm view diver name version description", + cwd: "/tmp/work", + }, + }, + } as never, + ); + + expect(ctx.state.deterministicApprovalPromptSent).toBe(false); + }); +}); + describe("messaging tool media URL tracking", () => { it("tracks media arg from messaging tool as pending", async () => { const { ctx } = createTestContext(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 8abd9469bbc99..70f6b54639c3f 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -1,5 +1,9 @@ import type { AgentEvent } from "@mariozechner/pi-agent-core"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { + buildExecApprovalPendingReplyPayload, + buildExecApprovalUnavailableReplyPayload, +} from "../infra/exec-approval-reply.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookAfterToolCallEvent } from "../plugins/types.js"; import { normalizeTextForComparison } from "./pi-embedded-helpers.js"; @@ -139,7 +143,81 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] { return urls; } -function emitToolResultOutput(params: { +function readExecApprovalPendingDetails(result: unknown): { + approvalId: string; + approvalSlug: string; + expiresAtMs?: number; + host: "gateway" | "node"; + command: string; + cwd?: string; + nodeId?: string; + warningText?: string; +} | null { + if (!result || typeof result !== "object") { + return null; + } + const outer = result as Record; + const details = + outer.details && typeof outer.details === "object" && !Array.isArray(outer.details) + ? (outer.details as Record) + : outer; + if (details.status !== "approval-pending") { + return null; + } + const approvalId = typeof details.approvalId === "string" ? details.approvalId.trim() : ""; + const approvalSlug = typeof details.approvalSlug === "string" ? details.approvalSlug.trim() : ""; + const command = typeof details.command === "string" ? details.command : ""; + const host = details.host === "node" ? "node" : details.host === "gateway" ? "gateway" : null; + if (!approvalId || !approvalSlug || !command || !host) { + return null; + } + return { + approvalId, + approvalSlug, + expiresAtMs: typeof details.expiresAtMs === "number" ? details.expiresAtMs : undefined, + host, + command, + cwd: typeof details.cwd === "string" ? details.cwd : undefined, + nodeId: typeof details.nodeId === "string" ? details.nodeId : undefined, + warningText: typeof details.warningText === "string" ? details.warningText : undefined, + }; +} + +function readExecApprovalUnavailableDetails(result: unknown): { + reason: "initiating-platform-disabled" | "initiating-platform-unsupported" | "no-approval-route"; + warningText?: string; + channelLabel?: string; + sentApproverDms?: boolean; +} | null { + if (!result || typeof result !== "object") { + return null; + } + const outer = result as Record; + const details = + outer.details && typeof outer.details === "object" && !Array.isArray(outer.details) + ? (outer.details as Record) + : outer; + if (details.status !== "approval-unavailable") { + return null; + } + const reason = + details.reason === "initiating-platform-disabled" || + details.reason === "initiating-platform-unsupported" || + details.reason === "no-approval-route" + ? details.reason + : null; + if (!reason) { + return null; + } + return { + reason, + warningText: typeof details.warningText === "string" ? details.warningText : undefined, + channelLabel: typeof details.channelLabel === "string" ? details.channelLabel : undefined, + sentApproverDms: details.sentApproverDms === true, + }; +} + +async function emitToolResultOutput(params: { ctx: ToolHandlerContext; toolName: string; meta?: string; @@ -152,6 +230,46 @@ function emitToolResultOutput(params: { return; } + const approvalPending = readExecApprovalPendingDetails(result); + if (!isToolError && approvalPending) { + try { + await ctx.params.onToolResult( + buildExecApprovalPendingReplyPayload({ + approvalId: approvalPending.approvalId, + approvalSlug: approvalPending.approvalSlug, + command: approvalPending.command, + cwd: approvalPending.cwd, + host: approvalPending.host, + nodeId: approvalPending.nodeId, + expiresAtMs: approvalPending.expiresAtMs, + warningText: approvalPending.warningText, + }), + ); + ctx.state.deterministicApprovalPromptSent = true; + } catch { + // ignore delivery failures + } + return; + } + + const approvalUnavailable = readExecApprovalUnavailableDetails(result); + if (!isToolError && approvalUnavailable) { + try { + await ctx.params.onToolResult?.( + buildExecApprovalUnavailableReplyPayload({ + reason: approvalUnavailable.reason, + warningText: approvalUnavailable.warningText, + channelLabel: approvalUnavailable.channelLabel, + sentApproverDms: approvalUnavailable.sentApproverDms, + }), + ); + ctx.state.deterministicApprovalPromptSent = true; + } catch { + // ignore delivery failures + } + return; + } + if (ctx.shouldEmitToolOutput()) { const outputText = extractToolResultText(sanitizedResult); if (outputText) { @@ -427,7 +545,7 @@ export async function handleToolExecutionEnd( `embedded run tool end: runId=${ctx.params.runId} tool=${toolName} toolCallId=${toolCallId}`, ); - emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); + await emitToolResultOutput({ ctx, toolName, meta, isToolError, result, sanitizedResult }); // Run after_tool_call plugin hook (fire-and-forget) const hookRunnerAfter = ctx.hookRunner ?? getGlobalHookRunner(); diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 955af473b9e91..4436e6f6aa3b1 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -76,6 +76,7 @@ export type EmbeddedPiSubscribeState = { pendingMessagingTargets: Map; successfulCronAdds: number; pendingMessagingMediaUrls: Map; + deterministicApprovalPromptSent: boolean; lastAssistant?: AgentMessage; }; @@ -155,6 +156,7 @@ export type ToolHandlerState = Pick< | "messagingToolSentMediaUrls" | "messagingToolSentTargets" | "successfulCronAdds" + | "deterministicApprovalPromptSent" >; export type ToolHandlerContext = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index c5ffedbf14f6c..83592372e807d 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -78,6 +78,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar pendingMessagingTargets: new Map(), successfulCronAdds: 0, pendingMessagingMediaUrls: new Map(), + deterministicApprovalPromptSent: false, }; const usageTotals = { input: 0, @@ -598,6 +599,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar pendingMessagingTargets.clear(); state.successfulCronAdds = 0; state.pendingMessagingMediaUrls.clear(); + state.deterministicApprovalPromptSent = false; resetAssistantMessageState(0); }; @@ -688,6 +690,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar // Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!") // which is generated AFTER the tool sends the actual answer. didSendViaMessagingTool: () => messagingToolSentTexts.length > 0, + didSendDeterministicApprovalPrompt: () => state.deterministicApprovalPromptSent, getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined), getUsageTotals, getCompactionCount: () => compactionCount, diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 689cd49998e6f..bbb2d552d7387 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -1,5 +1,6 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookRunner } from "../plugins/hooks.js"; import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; @@ -16,7 +17,7 @@ export type SubscribeEmbeddedPiSessionParams = { toolResultFormat?: ToolResultFormat; shouldEmitToolResult?: () => boolean; shouldEmitToolOutput?: () => boolean; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; + onToolResult?: (payload: ReplyPayload) => void | Promise; onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; /** Called when a thinking/reasoning block ends ( tag processed). */ onReasoningEnd?: () => void | Promise; diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/pi-tool-handler-state.test-helpers.ts index 0775299ab8309..cfb559b988452 100644 --- a/src/agents/pi-tool-handler-state.test-helpers.ts +++ b/src/agents/pi-tool-handler-state.test-helpers.ts @@ -10,6 +10,7 @@ export function createBaseToolHandlerState() { messagingToolSentTextsNormalized: [] as string[], messagingToolSentMediaUrls: [] as string[], messagingToolSentTargets: [] as unknown[], + deterministicApprovalPromptSent: false, blockBuffer: "", }; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index a3d593ab6b884..848222b7880e9 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -464,6 +464,9 @@ export function buildAgentSystemPrompt(params: { "Keep narration brief and value-dense; avoid repeating obvious steps.", "Use plain human language for narration unless in a technical context.", "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", + "When exec returns approval-pending, include the concrete /approve command from tool output (with allow-once|allow-always|deny) and do not ask for a different or rotated code.", + "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", + "When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run.", "", ...safetySection, "## OpenClaw CLI Quick Reference", diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index a3b31c4ccc3e6..2f6c27519b00c 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -445,8 +445,8 @@ export async function runAgentTurnWithFallback(params: { } await params.typingSignals.signalTextDelta(text); await onToolResult({ + ...payload, text, - mediaUrls: payload.mediaUrls, }); }) .catch((err) => { diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 350c6b63e47b0..5bf77cd9f7068 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -12,6 +12,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); const { + buildThreadingToolContext, buildEmbeddedRunBaseParams, buildEmbeddedRunContexts, resolveModelFallbackOptions, @@ -173,4 +174,44 @@ describe("agent-runner-utils", () => { expect(resolved.embeddedContext.messageProvider).toBe("telegram"); expect(resolved.embeddedContext.messageTo).toBe("268300329"); }); + + it("uses OriginatingTo for threading tool context on telegram native commands", () => { + const context = buildThreadingToolContext({ + sessionCtx: { + Provider: "telegram", + To: "slash:8460800771", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:-1003841603622", + MessageThreadId: 928, + MessageSid: "2284", + }, + config: { channels: { telegram: { allowFrom: ["*"] } } }, + hasRepliedRef: undefined, + }); + + expect(context).toMatchObject({ + currentChannelId: "telegram:-1003841603622", + currentThreadTs: "928", + currentMessageId: "2284", + }); + }); + + it("uses OriginatingTo for threading tool context on discord native commands", () => { + const context = buildThreadingToolContext({ + sessionCtx: { + Provider: "discord", + To: "slash:1177378744822943744", + OriginatingChannel: "discord", + OriginatingTo: "channel:123456789012345678", + MessageSid: "msg-9", + }, + config: {}, + hasRepliedRef: undefined, + }); + + expect(context).toMatchObject({ + currentChannelId: "channel:123456789012345678", + currentMessageId: "msg-9", + }); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 36e45bd9bf162..99b2b6392f62a 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -23,12 +23,20 @@ export function buildThreadingToolContext(params: { }): ChannelThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; + const originProvider = resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Provider, + }); + const originTo = resolveOriginMessageTo({ + originatingTo: sessionCtx.OriginatingTo, + to: sessionCtx.To, + }); if (!config) { return { currentMessageId, }; } - const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); + const rawProvider = originProvider?.trim().toLowerCase(); if (!rawProvider) { return { currentMessageId, @@ -39,7 +47,7 @@ export function buildThreadingToolContext(params: { const dock = provider ? getChannelDock(provider) : undefined; if (!dock?.threading?.buildToolContext) { return { - currentChannelId: sessionCtx.To?.trim() || undefined, + currentChannelId: originTo?.trim() || undefined, currentChannelProvider: provider ?? (rawProvider as ChannelId), currentMessageId, hasRepliedRef, @@ -50,9 +58,9 @@ export function buildThreadingToolContext(params: { cfg: config, accountId: sessionCtx.AccountId, context: { - Channel: sessionCtx.Provider, + Channel: originProvider, From: sessionCtx.From, - To: sessionCtx.To, + To: originTo, ChatType: sessionCtx.ChatType, CurrentMessageId: currentMessageId, ReplyToId: sessionCtx.ReplyToId, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 83c1796515c52..db034ac03a61f 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -21,7 +21,7 @@ type AgentRunParams = { onAssistantMessageStart?: () => Promise | void; onReasoningStream?: (payload: { text?: string }) => Promise | void; onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; - onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise | void; + onToolResult?: (payload: ReplyPayload) => Promise | void; onAgentEvent?: (evt: { stream: string; data: Record }) => void; }; @@ -594,6 +594,40 @@ describe("runReplyAgent typing (heartbeat)", () => { } }); + it("preserves channelData on forwarded tool results", async () => { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); + + expect(onToolResult).toHaveBeenCalledWith({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + }); + it("retries transient HTTP failures once with timer-driven backoff", async () => { vi.useFakeTimers(); let calls = 0; @@ -1952,3 +1986,4 @@ describe("runReplyAgent memory flush", () => { }); }); }); +import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 9773ba03ad550..5b0caec9c8f94 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,10 +1,15 @@ import { callGateway } from "../../gateway/call.js"; import { logVerbose } from "../../globals.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, +} from "../../telegram/exec-approvals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; -const COMMAND = "/approve"; +const COMMAND_REGEX = /^\/approve(?:\s|$)/i; +const FOREIGN_COMMAND_MENTION_REGEX = /^\/approve@([^\s]+)(?:\s|$)/i; const DECISION_ALIASES: Record = { allow: "allow-once", @@ -25,10 +30,14 @@ type ParsedApproveCommand = function parseApproveCommand(raw: string): ParsedApproveCommand | null { const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith(COMMAND)) { + if (FOREIGN_COMMAND_MENTION_REGEX.test(trimmed)) { + return { ok: false, error: "❌ This /approve command targets a different Telegram bot." }; + } + const commandMatch = trimmed.match(COMMAND_REGEX); + if (!commandMatch) { return null; } - const rest = trimmed.slice(COMMAND.length).trim(); + const rest = trimmed.slice(commandMatch[0].length).trim(); if (!rest) { return { ok: false, error: "Usage: /approve allow-once|allow-always|deny" }; } @@ -83,6 +92,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { text: parsed.error } }; } + if (params.command.channel === "telegram") { + if ( + !isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId }) + ) { + return { + shouldContinue: false, + reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." }, + }; + } + if ( + !isTelegramExecApprovalApprover({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + senderId: params.command.senderId, + }) + ) { + return { + shouldContinue: false, + reply: { text: "❌ You are not authorized to approve exec requests on Telegram." }, + }; + } + } + const missingScope = requireGatewayClientScopeForInternalChannel(params, { label: "/approve", allowedScopes: ["operator.approvals", "operator.admin"], diff --git a/src/auto-reply/reply/commands-context.ts b/src/auto-reply/reply/commands-context.ts index 3d177c2b5f99b..1c5056b4b464c 100644 --- a/src/auto-reply/reply/commands-context.ts +++ b/src/auto-reply/reply/commands-context.ts @@ -26,6 +26,7 @@ export function buildCommandContext(params: { const rawBodyNormalized = triggerBodyNormalized; const commandBodyNormalized = normalizeCommandBody( isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized, + { botUsername: ctx.BotUsername }, ); return { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 38be7c435315a..0f526d6edaa18 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -105,27 +105,6 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -type ResetAcpSessionInPlaceResult = { ok: true } | { ok: false; skipped?: boolean; error?: string }; - -const resetAcpSessionInPlaceMock = vi.hoisted(() => - vi.fn( - async (_params: unknown): Promise => ({ - ok: false, - skipped: true, - }), - ), -); -vi.mock("../../acp/persistent-bindings.js", async () => { - const actual = await vi.importActual( - "../../acp/persistent-bindings.js", - ); - return { - ...actual, - resetAcpSessionInPlace: (params: unknown) => resetAcpSessionInPlaceMock(params), - }; -}); - -import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { buildCommandContext, handleCommands } from "./commands.js"; @@ -158,11 +137,6 @@ function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Pa return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir }); } -beforeEach(() => { - resetAcpSessionInPlaceMock.mockReset(); - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, skipped: true } as const); -}); - describe("handleCommands gating", () => { it("blocks gated commands when disabled or not elevated-allowlisted", async () => { const cases = typedCases<{ @@ -316,6 +290,122 @@ describe("/approve command", () => { ); }); + it("accepts Telegram command mentions for /approve", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve@bot abc12345 allow-once", cfg, { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("rejects Telegram /approve mentions targeting a different bot", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve@otherbot abc12345 allow-once", cfg, { + BotUsername: "bot", + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("targets a different Telegram bot"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("surfaces unknown or expired approval id errors", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("unknown or expired approval id"); + }); + + it("rejects Telegram /approve when telegram exec approvals are disabled", async () => { + const cfg = { + commands: { text: true }, + channels: { telegram: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Telegram exec approvals are not enabled"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects Telegram /approve from non-approvers", async () => { + const cfg = { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + execApprovals: { enabled: true, approvers: ["999"], target: "dm" }, + }, + }, + } as OpenClawConfig; + const params = buildParams("/approve abc12345 allow-once", cfg, { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("not authorized to approve"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("rejects gateway clients without approvals scope", async () => { const cfg = { commands: { text: true }, @@ -1147,226 +1237,6 @@ describe("handleCommands hooks", () => { }); }); -describe("handleCommands ACP-bound /new and /reset", () => { - const discordChannelId = "1478836151241412759"; - const buildDiscordBoundConfig = (): OpenClawConfig => - ({ - commands: { text: true }, - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "discord", - accountId: "default", - peer: { - kind: "channel", - id: discordChannelId, - }, - }, - acp: { - mode: "persistent", - }, - }, - ], - channels: { - discord: { - allowFrom: ["*"], - guilds: { "1459246755253325866": { channels: { [discordChannelId]: {} } } }, - }, - }, - }) as OpenClawConfig; - - const buildDiscordBoundParams = (body: string) => { - const params = buildParams(body, buildDiscordBoundConfig(), { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: "default", - SenderId: "12345", - From: "discord:12345", - To: discordChannelId, - OriginatingTo: discordChannelId, - SessionKey: "agent:main:acp:binding:discord:default:feedface", - }); - params.sessionKey = "agent:main:acp:binding:discord:default:feedface"; - return params; - }; - - it("handles /new as ACP in-place reset for bound conversations", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const result = await handleCommands(buildDiscordBoundParams("/new")); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - reason: "new", - }); - }); - - it("continues with trailing prompt text after successful ACP-bound /new", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const params = buildDiscordBoundParams("/new continue with deployment"); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - const mutableCtx = params.ctx as Record; - expect(mutableCtx.BodyStripped).toBe("continue with deployment"); - expect(mutableCtx.CommandBody).toBe("continue with deployment"); - expect(mutableCtx.AcpDispatchTailAfterReset).toBe(true); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - }); - - it("handles /reset failures without falling back to normal session reset flow", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); - const result = await handleCommands(buildDiscordBoundParams("/reset")); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset failed"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - reason: "reset", - }); - }); - - it("does not emit reset hooks when ACP reset fails", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: false, error: "backend unavailable" }); - const spy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - - const result = await handleCommands(buildDiscordBoundParams("/reset")); - - expect(result.shouldContinue).toBe(false); - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); - - it("keeps existing /new behavior for non-ACP sessions", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const result = await handleCommands(buildParams("/new", cfg)); - - expect(result.shouldContinue).toBe(true); - expect(resetAcpSessionInPlaceMock).not.toHaveBeenCalled(); - }); - - it("still targets configured ACP binding when runtime routing falls back to a non-ACP session", async () => { - const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; - const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ - channel: "discord", - accountId: "default", - conversationId: discordChannelId, - agentId: "codex", - mode: "persistent", - }); - const params = buildDiscordBoundParams("/new"); - params.sessionKey = fallbackSessionKey; - params.ctx.SessionKey = fallbackSessionKey; - params.ctx.CommandTargetSessionKey = fallbackSessionKey; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset unavailable"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - sessionKey: configuredAcpSessionKey, - reason: "new", - }); - }); - - it("emits reset hooks for the ACP session key when routing falls back to non-ACP session", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const hookSpy = vi.spyOn(internalHooks, "triggerInternalHook").mockResolvedValue(); - const fallbackSessionKey = `agent:main:discord:channel:${discordChannelId}`; - const configuredAcpSessionKey = buildConfiguredAcpSessionKey({ - channel: "discord", - accountId: "default", - conversationId: discordChannelId, - agentId: "codex", - mode: "persistent", - }); - const fallbackEntry = { - sessionId: "fallback-session-id", - sessionFile: "/tmp/fallback-session.jsonl", - } as SessionEntry; - const configuredEntry = { - sessionId: "configured-acp-session-id", - sessionFile: "/tmp/configured-acp-session.jsonl", - } as SessionEntry; - const params = buildDiscordBoundParams("/new"); - params.sessionKey = fallbackSessionKey; - params.ctx.SessionKey = fallbackSessionKey; - params.ctx.CommandTargetSessionKey = fallbackSessionKey; - params.sessionEntry = fallbackEntry; - params.previousSessionEntry = fallbackEntry; - params.sessionStore = { - [fallbackSessionKey]: fallbackEntry, - [configuredAcpSessionKey]: configuredEntry, - }; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(hookSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "command", - action: "new", - sessionKey: configuredAcpSessionKey, - context: expect.objectContaining({ - sessionEntry: configuredEntry, - previousSessionEntry: configuredEntry, - }), - }), - ); - hookSpy.mockRestore(); - }); - - it("uses active ACP command target when conversation binding context is missing", async () => { - resetAcpSessionInPlaceMock.mockResolvedValue({ ok: true } as const); - const activeAcpTarget = "agent:codex:acp:binding:discord:default:feedface"; - const params = buildParams( - "/new", - { - commands: { text: true }, - channels: { - discord: { - allowFrom: ["*"], - }, - }, - } as OpenClawConfig, - { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - AccountId: "default", - SenderId: "12345", - From: "discord:12345", - }, - ); - params.sessionKey = "discord:slash:12345"; - params.ctx.SessionKey = "discord:slash:12345"; - params.ctx.CommandSource = "native"; - params.ctx.CommandTargetSessionKey = activeAcpTarget; - params.ctx.To = "user:12345"; - params.ctx.OriginatingTo = "user:12345"; - - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("ACP session reset in place"); - expect(resetAcpSessionInPlaceMock).toHaveBeenCalledTimes(1); - expect(resetAcpSessionInPlaceMock.mock.calls[0]?.[0]).toMatchObject({ - sessionKey: activeAcpTarget, - reason: "new", - }); - }); -}); - describe("handleCommands context", () => { it("returns expected details for /context commands", async () => { const cfg = { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 982557ecb6803..87e77785bbbc3 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -543,6 +543,51 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("delivers deterministic exec approval tool payloads in groups", async () => { + setNoAbort(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + ChatType: "group", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onToolResult?.({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { text: "NO_REPLY" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); + expect(firstToolResultPayload(dispatcher)).toEqual( + expect.objectContaining({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }), + ); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" }); + }); + it("sends tool results via dispatcher in DM sessions", async () => { setNoAbort(); const cfg = emptyConfig; @@ -601,6 +646,50 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); + it("delivers deterministic exec approval tool payloads for native commands", async () => { + setNoAbort(); + const cfg = emptyConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "telegram", + CommandSource: "native", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onToolResult?.({ + text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { text: "NO_REPLY" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); + expect(firstToolResultPayload(dispatcher)).toEqual( + expect.objectContaining({ + channelData: { + execApproval: { + approvalId: "117ba06d-1111-2222-3333-444444444444", + approvalSlug: "117ba06d", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }), + ); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" }); + }); + it("fast-aborts without calling the reply resolver", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: true, @@ -1539,6 +1628,47 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("suppresses local discord exec approval tool prompts when discord approvals are enabled", async () => { + setNoAbort(); + const cfg = { + channels: { + discord: { + enabled: true, + execApprovals: { + enabled: true, + approvers: ["123"], + }, + }, + }, + } as OpenClawConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + AccountId: "default", + }); + const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => { + await options?.onToolResult?.({ + text: "Approval required.", + channelData: { + execApproval: { + approvalId: "12345678-1234-1234-1234-123456789012", + approvalSlug: "12345678", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }); + return { text: "done" } as ReplyPayload; + }); + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "done" }), + ); + }); + it("deduplicates same-agent inbound replies across main and direct session keys", async () => { setNoAbort(); const cfg = emptyConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 786b1a7c16b52..5b250b033623a 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -6,6 +6,7 @@ import { resolveStorePath, type SessionEntry, } from "../../config/sessions.js"; +import { shouldSuppressLocalDiscordExecApprovalPrompt } from "../../discord/exec-approvals.js"; import { logVerbose } from "../../globals.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -365,9 +366,28 @@ export async function dispatchReplyFromConfig(params: { let blockCount = 0; const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { + if ( + normalizeMessageChannel(ctx.Surface ?? ctx.Provider) === "discord" && + shouldSuppressLocalDiscordExecApprovalPrompt({ + cfg, + accountId: ctx.AccountId, + payload, + }) + ) { + return null; + } if (shouldSendToolSummaries) { return payload; } + const execApproval = + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) + ? payload.channelData.execApproval + : undefined; + if (execApproval && typeof execApproval === "object" && !Array.isArray(execApproval)) { + return payload; + } // Group/native flows intentionally suppress tool summary text, but media-only // tool results (for example TTS audio) must still be delivered. const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index cc4fc49e93f3e..8ca3c2389bcb0 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -132,6 +132,8 @@ export type MsgContext = { Provider?: string; /** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */ Surface?: string; + /** Platform bot username when command mentions should be normalized. */ + BotUsername?: string; WasMentioned?: boolean; CommandAuthorized?: boolean; CommandSource?: "text" | "native"; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 2a079a6014e9c..2afc67d439d33 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -115,7 +115,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { quoteText, mediaLocalRoots, }; - if (mediaUrls.length === 0) { const result = await send(to, text, { ...payloadOpts, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index fa9451456bf15..04d5200bfbb4f 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -522,6 +522,12 @@ const CHANNELS_AGENTS_TARGET_KEYS = [ "channels.telegram", "channels.telegram.botToken", "channels.telegram.capabilities.inlineButtons", + "channels.telegram.execApprovals", + "channels.telegram.execApprovals.enabled", + "channels.telegram.execApprovals.approvers", + "channels.telegram.execApprovals.agentFilter", + "channels.telegram.execApprovals.sessionFilter", + "channels.telegram.execApprovals.target", "channels.whatsapp", ] as const; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 08c579f89e3de..908829cbf3357 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1383,6 +1383,18 @@ export const FIELD_HELP: Record = { "Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected.", "channels.telegram.capabilities.inlineButtons": "Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior.", + "channels.telegram.execApprovals": + "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.", + "channels.telegram.execApprovals.enabled": + "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.", + "channels.telegram.execApprovals.approvers": + "Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs; prompts are only delivered to these approvers when target includes dm.", + "channels.telegram.execApprovals.agentFilter": + 'Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.', + "channels.telegram.execApprovals.sessionFilter": + "Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.", + "channels.telegram.execApprovals.target": + 'Controls where Telegram approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Telegram chat/topic, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.', "channels.slack.configWrites": "Allow Slack to write config in response to channel events/commands (default: true).", "channels.slack.botToken": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 16bf21e8daf0a..c643cf91cd90b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -719,6 +719,12 @@ export const FIELD_LABELS: Record = { "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.telegram.execApprovals": "Telegram Exec Approvals", + "channels.telegram.execApprovals.enabled": "Telegram Exec Approvals Enabled", + "channels.telegram.execApprovals.approvers": "Telegram Exec Approval Approvers", + "channels.telegram.execApprovals.agentFilter": "Telegram Exec Approval Agent Filter", + "channels.telegram.execApprovals.sessionFilter": "Telegram Exec Approval Session Filter", + "channels.telegram.execApprovals.target": "Telegram Exec Approval Target", "channels.telegram.threadBindings.enabled": "Telegram Thread Binding Enabled", "channels.telegram.threadBindings.idleHours": "Telegram Thread Binding Idle Timeout (hours)", "channels.telegram.threadBindings.maxAgeHours": "Telegram Thread Binding Max Age (hours)", diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index ce8ad105b06aa..41c047e860c61 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -38,6 +38,20 @@ export type TelegramNetworkConfig = { export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; export type TelegramStreamingMode = "off" | "partial" | "block" | "progress"; +export type TelegramExecApprovalTarget = "dm" | "channel" | "both"; + +export type TelegramExecApprovalConfig = { + /** Enable Telegram exec approvals for this account. Default: false. */ + enabled?: boolean; + /** Telegram user IDs allowed to approve exec requests. Required if enabled. */ + approvers?: Array; + /** Only forward approvals for these agent IDs. Omit = all agents. */ + agentFilter?: string[]; + /** Only forward approvals matching these session key patterns (substring or regex). */ + sessionFilter?: string[]; + /** Where to send approval prompts. Default: "dm". */ + target?: TelegramExecApprovalTarget; +}; export type TelegramCapabilitiesConfig = | string[] @@ -58,6 +72,8 @@ export type TelegramAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: TelegramCapabilitiesConfig; + /** Telegram-native exec approval delivery + approver authorization. */ + execApprovals?: TelegramExecApprovalConfig; /** Markdown formatting overrides (tables). */ markdown?: MarkdownConfig; /** Override native command registration for Telegram (bool or "auto"). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ac1287460bd25..3ceefb480ff9b 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -49,6 +49,7 @@ const DiscordIdSchema = z const DiscordIdListSchema = z.array(DiscordIdSchema); const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); +const TelegramIdListSchema = z.array(z.union([z.string(), z.number()])); const TelegramCapabilitiesSchema = z.union([ z.array(z.string()), @@ -153,6 +154,16 @@ export const TelegramAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: TelegramCapabilitiesSchema.optional(), + execApprovals: z + .object({ + enabled: z.boolean().optional(), + approvers: TelegramIdListSchema.optional(), + agentFilter: z.array(z.string()).optional(), + sessionFilter: z.array(z.string()).optional(), + target: z.enum(["dm", "channel", "both"]).optional(), + }) + .strict() + .optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, diff --git a/src/discord/exec-approvals.ts b/src/discord/exec-approvals.ts new file mode 100644 index 0000000000000..f4be9a22e0cf4 --- /dev/null +++ b/src/discord/exec-approvals.ts @@ -0,0 +1,23 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import { resolveDiscordAccount } from "./accounts.js"; + +export function isDiscordExecApprovalClientEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const config = resolveDiscordAccount(params).config.execApprovals; + return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0); +} + +export function shouldSuppressLocalDiscordExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; +}): boolean { + return ( + isDiscordExecApprovalClientEnabled(params) && + getExecApprovalReplyMetadata(params.payload) !== null + ); +} diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index f5e607022ee26..8f9430393a221 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -470,15 +470,15 @@ describe("ExecApprovalButton", () => { function createMockInteraction(userId: string) { const reply = vi.fn().mockResolvedValue(undefined); - const update = vi.fn().mockResolvedValue(undefined); + const acknowledge = vi.fn().mockResolvedValue(undefined); const followUp = vi.fn().mockResolvedValue(undefined); const interaction = { userId, reply, - update, + acknowledge, followUp, } as unknown as ButtonInteraction; - return { interaction, reply, update, followUp }; + return { interaction, reply, acknowledge, followUp }; } it("denies unauthorized users with ephemeral message", async () => { @@ -486,7 +486,7 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, reply, update } = createMockInteraction("999"); + const { interaction, reply, acknowledge } = createMockInteraction("999"); const data: ComponentData = { id: "test-approval", action: "allow-once" }; await button.run(interaction, data); @@ -495,7 +495,7 @@ describe("ExecApprovalButton", () => { content: "⛔ You are not authorized to approve exec requests.", ephemeral: true, }); - expect(update).not.toHaveBeenCalled(); + expect(acknowledge).not.toHaveBeenCalled(); // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock expect(handler.resolveApproval).not.toHaveBeenCalled(); }); @@ -505,50 +505,45 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, reply, update } = createMockInteraction("222"); + const { interaction, reply, acknowledge } = createMockInteraction("222"); const data: ComponentData = { id: "test-approval", action: "allow-once" }; await button.run(interaction, data); expect(reply).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalledWith({ - content: "Submitting decision: **Allowed (once)**...", - components: [], - }); + expect(acknowledge).toHaveBeenCalledTimes(1); // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-once"); }); - it("shows correct label for allow-always", async () => { + it("acknowledges allow-always interactions before resolving", async () => { const handler = createMockHandler(["111"]); const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update } = createMockInteraction("111"); + const { interaction, acknowledge } = createMockInteraction("111"); const data: ComponentData = { id: "test-approval", action: "allow-always" }; await button.run(interaction, data); - expect(update).toHaveBeenCalledWith({ - content: "Submitting decision: **Allowed (always)**...", - components: [], - }); + expect(acknowledge).toHaveBeenCalledTimes(1); + // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock + expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "allow-always"); }); - it("shows correct label for deny", async () => { + it("acknowledges deny interactions before resolving", async () => { const handler = createMockHandler(["111"]); const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update } = createMockInteraction("111"); + const { interaction, acknowledge } = createMockInteraction("111"); const data: ComponentData = { id: "test-approval", action: "deny" }; await button.run(interaction, data); - expect(update).toHaveBeenCalledWith({ - content: "Submitting decision: **Denied**...", - components: [], - }); + expect(acknowledge).toHaveBeenCalledTimes(1); + // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock + expect(handler.resolveApproval).toHaveBeenCalledWith("test-approval", "deny"); }); it("handles invalid data gracefully", async () => { @@ -556,18 +551,20 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update } = createMockInteraction("111"); + const { interaction, acknowledge, reply } = createMockInteraction("111"); const data: ComponentData = { id: "", action: "invalid" }; await button.run(interaction, data); - expect(update).toHaveBeenCalledWith({ + expect(reply).toHaveBeenCalledWith({ content: "This approval is no longer valid.", - components: [], + ephemeral: true, }); + expect(acknowledge).not.toHaveBeenCalled(); // oxlint-disable-next-line typescript/unbound-method -- vi.fn() mock expect(handler.resolveApproval).not.toHaveBeenCalled(); }); + it("follows up with error when resolve fails", async () => { const handler = createMockHandler(["111"]); handler.resolveApproval = vi.fn().mockResolvedValue(false); @@ -581,7 +578,7 @@ describe("ExecApprovalButton", () => { expect(followUp).toHaveBeenCalledWith({ content: - "Failed to submit approval decision. The request may have expired or already been resolved.", + "Failed to submit approval decision for **Allowed (once)**. The request may have expired or already been resolved.", ephemeral: true, }); }); @@ -596,14 +593,14 @@ describe("ExecApprovalButton", () => { const ctx: ExecApprovalButtonContext = { handler }; const button = new ExecApprovalButton(ctx); - const { interaction, update, reply } = createMockInteraction("111"); + const { interaction, acknowledge, reply } = createMockInteraction("111"); const data: ComponentData = { id: "test-approval", action: "allow-once" }; await button.run(interaction, data); // Should match because getApprovers returns [111] and button does String(id) === userId expect(reply).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalled(); + expect(acknowledge).toHaveBeenCalled(); }); }); @@ -803,6 +800,80 @@ describe("DiscordExecApprovalHandler delivery routing", () => { clearPendingTimeouts(handler); }); + + it("posts an in-channel note when target is dm and the request came from a non-DM discord conversation", async () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + target: "dm", + }); + const internals = getHandlerInternals(handler); + + mockRestPost.mockImplementation( + async (route: string, params?: { body?: { content?: string } }) => { + if (route === Routes.channelMessages("999888777")) { + expect(params?.body?.content).toContain("I sent the allowed approvers DMs"); + return { id: "note-1", channel_id: "999888777" }; + } + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + throw new Error(`unexpected route: ${route}`); + }, + ); + + await internals.handleApprovalRequested(createRequest()); + + expect(mockRestPost).toHaveBeenCalledWith( + Routes.channelMessages("999888777"), + expect.objectContaining({ + body: expect.objectContaining({ + content: expect.stringContaining("I sent the allowed approvers DMs"), + }), + }), + ); + expect(mockRestPost).toHaveBeenCalledWith( + Routes.channelMessages("dm-1"), + expect.objectContaining({ + body: expect.any(Object), + }), + ); + + clearPendingTimeouts(handler); + }); + + it("does not post an in-channel note when the request already came from a discord DM", async () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + target: "dm", + }); + const internals = getHandlerInternals(handler); + + mockRestPost.mockImplementation(async (route: string) => { + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + throw new Error(`unexpected route: ${route}`); + }); + + await internals.handleApprovalRequested( + createRequest({ sessionKey: "agent:main:discord:dm:123" }), + ); + + expect(mockRestPost).not.toHaveBeenCalledWith( + Routes.channelMessages("999888777"), + expect.anything(), + ); + + clearPendingTimeouts(handler); + }); }); describe("DiscordExecApprovalHandler gateway auth resolution", () => { diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 5564b126e3c35..f426ae51903c5 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -17,6 +17,7 @@ import { buildGatewayConnectionDetails } from "../../gateway/call.js"; import { GatewayClient } from "../../gateway/client.js"; import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js"; import type { EventFrame } from "../../gateway/protocol/index.js"; +import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, @@ -47,6 +48,12 @@ export function extractDiscordChannelId(sessionKey?: string | null): string | nu return match ? match[1] : null; } +function buildDiscordApprovalDmRedirectNotice(): { content: string } { + return { + content: getExecApprovalApproverDmNoticeText(), + }; +} + type PendingApproval = { discordMessageId: string; discordChannelId: string; @@ -498,6 +505,24 @@ export class DiscordExecApprovalHandler { const sendToDm = target === "dm" || target === "both"; const sendToChannel = target === "channel" || target === "both"; let fallbackToDm = false; + const originatingChannelId = + request.request.sessionKey && target === "dm" + ? extractDiscordChannelId(request.request.sessionKey) + : null; + + if (target === "dm" && originatingChannelId) { + try { + await discordRequest( + () => + rest.post(Routes.channelMessages(originatingChannelId), { + body: buildDiscordApprovalDmRedirectNotice(), + }) as Promise<{ id: string; channel_id: string }>, + "send-approval-dm-redirect-notice", + ); + } catch (err) { + logError(`discord exec approvals: failed to send DM redirect notice: ${String(err)}`); + } + } // Send to originating channel if configured if (sendToChannel) { @@ -768,9 +793,9 @@ export class ExecApprovalButton extends Button { const parsed = parseExecApprovalData(data); if (!parsed) { try { - await interaction.update({ + await interaction.reply({ content: "This approval is no longer valid.", - components: [], + ephemeral: true, }); } catch { // Interaction may have expired @@ -800,12 +825,11 @@ export class ExecApprovalButton extends Button { ? "Allowed (always)" : "Denied"; - // Update the message immediately to show the decision + // Acknowledge immediately so Discord does not fail the interaction while + // the gateway resolve roundtrip completes. The resolved event will update + // the approval card in-place with the final state. try { - await interaction.update({ - content: `Submitting decision: **${decisionLabel}**...`, - components: [], // Remove buttons - }); + await interaction.acknowledge(); } catch { // Interaction may have expired, try to continue anyway } @@ -815,8 +839,7 @@ export class ExecApprovalButton extends Button { if (!ok) { try { await interaction.followUp({ - content: - "Failed to submit approval decision. The request may have expired or already been resolved.", + content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`, ephemeral: true, }); } catch { diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 320b4da0b1f00..e0176470a03ce 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -31,6 +31,11 @@ type PendingEntry = { promise: Promise; }; +export type ExecApprovalIdLookupResult = + | { kind: "exact" | "prefix"; id: string } + | { kind: "ambiguous"; ids: string[] } + | { kind: "none" }; + export class ExecApprovalManager { private pending = new Map(); @@ -170,4 +175,37 @@ export class ExecApprovalManager { const entry = this.pending.get(recordId); return entry?.promise ?? null; } + + lookupPendingId(input: string): ExecApprovalIdLookupResult { + const normalized = input.trim(); + if (!normalized) { + return { kind: "none" }; + } + + const exact = this.pending.get(normalized); + if (exact) { + return exact.record.resolvedAtMs === undefined + ? { kind: "exact", id: normalized } + : { kind: "none" }; + } + + const lowerPrefix = normalized.toLowerCase(); + const matches: string[] = []; + for (const [id, entry] of this.pending.entries()) { + if (entry.record.resolvedAtMs !== undefined) { + continue; + } + if (id.toLowerCase().startsWith(lowerPrefix)) { + matches.push(id); + } + } + + if (matches.length === 1) { + return { kind: "prefix", id: matches[0] }; + } + if (matches.length > 1) { + return { kind: "ambiguous", ids: matches }; + } + return { kind: "none" }; + } } diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 1099896f6c84c..b077204e4ba09 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -23,6 +23,7 @@ type SystemRunParamsLike = { approved?: unknown; approvalDecision?: unknown; runId?: unknown; + suppressNotifyOnExit?: unknown; }; type ApprovalLookup = { @@ -78,6 +79,7 @@ function pickSystemRunParams(raw: Record): Record boolean }) => { - if (typeof context.hasExecApprovalClients === "function") { - return context.hasExecApprovalClients(); - } - // Fail closed when no operator-scope probe is available. - return false; - }; - return { "exec.approval.request": async ({ params, respond, context, client }) => { if (!validateExecApprovalRequestParams(params)) { @@ -178,10 +170,11 @@ export function createExecApprovalHandlers( }, { dropIfSlow: true }, ); - let forwardedToTargets = false; + const hasExecApprovalClients = context.hasExecApprovalClients?.() ?? false; + let forwarded = false; if (opts?.forwarder) { try { - forwardedToTargets = await opts.forwarder.handleRequested({ + forwarded = await opts.forwarder.handleRequested({ id: record.id, request: record.request, createdAtMs: record.createdAtMs, @@ -192,8 +185,19 @@ export function createExecApprovalHandlers( } } - if (!hasApprovalClients(context) && !forwardedToTargets) { - manager.expire(record.id, "auto-expire:no-approver-clients"); + if (!hasExecApprovalClients && !forwarded) { + manager.expire(record.id, "no-approval-route"); + respond( + true, + { + id: record.id, + decision: null, + createdAtMs: record.createdAtMs, + expiresAtMs: record.expiresAtMs, + }, + undefined, + ); + return; } // Only send immediate "accepted" response when twoPhase is requested. @@ -275,21 +279,48 @@ export function createExecApprovalHandlers( respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision")); return; } - const snapshot = manager.getSnapshot(p.id); + const resolvedId = manager.lookupPendingId(p.id); + if (resolvedId.kind === "none") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"), + ); + return; + } + if (resolvedId.kind === "ambiguous") { + const candidates = resolvedId.ids.slice(0, 3).join(", "); + const remainder = resolvedId.ids.length > 3 ? ` (+${resolvedId.ids.length - 3} more)` : ""; + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `ambiguous approval id prefix; matches: ${candidates}${remainder}. Use the full id.`, + ), + ); + return; + } + const approvalId = resolvedId.id; + const snapshot = manager.getSnapshot(approvalId); const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id; - const ok = manager.resolve(p.id, decision, resolvedBy ?? null); + const ok = manager.resolve(approvalId, decision, resolvedBy ?? null); if (!ok) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id")); + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id"), + ); return; } context.broadcast( "exec.approval.resolved", - { id: p.id, decision, resolvedBy, ts: Date.now(), request: snapshot?.request }, + { id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request }, { dropIfSlow: true }, ); void opts?.forwarder ?.handleResolved({ - id: p.id, + id: approvalId, decision, resolvedBy, ts: Date.now(), diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 4ea91ea247f4d..2292a1c808c22 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -531,6 +531,19 @@ describe("exec approval handlers", () => { expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); }); + it("does not reuse a resolved exact id as a prefix for another pending approval", () => { + const manager = new ExecApprovalManager(); + const resolvedRecord = manager.create({ command: "echo old", host: "gateway" }, 2_000, "abc"); + void manager.register(resolvedRecord, 2_000); + expect(manager.resolve("abc", "allow-once")).toBe(true); + + const pendingRecord = manager.create({ command: "echo new", host: "gateway" }, 2_000, "abcdef"); + void manager.register(pendingRecord, 2_000); + + expect(manager.lookupPendingId("abc")).toEqual({ kind: "none" }); + expect(manager.lookupPendingId("abcdef")).toEqual({ kind: "exact", id: "abcdef" }); + }); + it("stores versioned system.run binding and sorted env keys on approval request", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ @@ -666,6 +679,134 @@ describe("exec approval handlers", () => { expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); + it("accepts unique short approval id prefixes", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const respond = vi.fn(); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + }; + + const record = manager.create({ command: "echo ok" }, 60_000, "approval-12345678-aaaa"); + void manager.register(record, 60_000); + + await resolveExecApproval({ + handlers, + id: "approval-1234", + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once"); + }); + + it("rejects ambiguous short approval id prefixes", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const respond = vi.fn(); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + }; + + void manager.register( + manager.create({ command: "echo one" }, 60_000, "approval-abcd-1111"), + 60_000, + ); + void manager.register( + manager.create({ command: "echo two" }, 60_000, "approval-abcd-2222"), + 60_000, + ); + + await resolveExecApproval({ + handlers, + id: "approval-abcd", + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("ambiguous approval id prefix"), + }), + ); + }); + + it("returns deterministic unknown/expired message for missing approval ids", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + + await resolveExecApproval({ + handlers, + id: "missing-approval-id", + respond, + context, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "unknown or expired approval id", + }), + ); + }); + + it("resolves only the targeted approval id when multiple requests are pending", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const context = { + broadcast: (_event: string, _payload: unknown) => {}, + hasExecApprovalClients: () => true, + }; + const respondOne = vi.fn(); + const respondTwo = vi.fn(); + + const requestOne = requestExecApproval({ + handlers, + respond: respondOne, + context, + params: { id: "approval-one", host: "gateway", timeoutMs: 60_000 }, + }); + const requestTwo = requestExecApproval({ + handlers, + respond: respondTwo, + context, + params: { id: "approval-two", host: "gateway", timeoutMs: 60_000 }, + }); + + await drainApprovalRequestTicks(); + + const resolveRespond = vi.fn(); + await resolveExecApproval({ + handlers, + id: "approval-one", + respond: resolveRespond, + context, + }); + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(manager.getSnapshot("approval-one")?.decision).toBe("allow-once"); + expect(manager.getSnapshot("approval-two")?.decision).toBeUndefined(); + expect(manager.getSnapshot("approval-two")?.resolvedAtMs).toBeUndefined(); + + expect(manager.expire("approval-two", "test-expire")).toBe(true); + await requestOne; + await requestTwo; + + expect(respondOne).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-one", decision: "allow-once" }), + undefined, + ); + expect(respondTwo).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-two", decision: null }), + undefined, + ); + }); + it("forwards turn-source metadata to exec approval forwarding", async () => { vi.useFakeTimers(); try { @@ -703,32 +844,59 @@ describe("exec approval handlers", () => { } }); - it("expires immediately when no approver clients and no forwarding targets", async () => { - vi.useFakeTimers(); - try { - const { manager, handlers, forwarder, respond, context } = - createForwardingExecApprovalFixture(); - const expireSpy = vi.spyOn(manager, "expire"); + it("fast-fails approvals when no approver clients and no forwarding targets", async () => { + const { manager, handlers, forwarder, respond, context } = + createForwardingExecApprovalFixture(); + const expireSpy = vi.spyOn(manager, "expire"); - const requestPromise = requestExecApproval({ - handlers, - respond, - context, - params: { timeoutMs: 60_000 }, - }); - await drainApprovalRequestTicks(); - expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); - expect(expireSpy).toHaveBeenCalledTimes(1); - await vi.runOnlyPendingTimersAsync(); - await requestPromise; - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ decision: null }), - undefined, - ); - } finally { - vi.useRealTimers(); - } + await requestExecApproval({ + handlers, + respond, + context, + params: { timeoutMs: 60_000, id: "approval-no-approver", host: "gateway" }, + }); + + expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); + expect(expireSpy).toHaveBeenCalledWith("approval-no-approver", "no-approval-route"); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-no-approver", decision: null }), + undefined, + ); + }); + + it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => { + const { manager, handlers, forwarder, respond, context } = + createForwardingExecApprovalFixture(); + const expireSpy = vi.spyOn(manager, "expire"); + const resolveRespond = vi.fn(); + forwarder.handleRequested.mockResolvedValueOnce(true); + + const requestPromise = requestExecApproval({ + handlers, + respond, + context, + params: { timeoutMs: 60_000, id: "approval-forwarded", host: "gateway" }, + }); + await drainApprovalRequestTicks(); + + expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); + expect(expireSpy).not.toHaveBeenCalled(); + + await resolveExecApproval({ + handlers, + id: "approval-forwarded", + respond: resolveRespond, + context, + }); + await requestPromise; + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-forwarded", decision: "allow-once" }), + undefined, + ); }); }); diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 46b3689642d14..a8885a64a6349 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -492,6 +492,23 @@ describe("notifications changed events", () => { expect(enqueueSystemEventMock).toHaveBeenCalledTimes(2); expect(requestHeartbeatNowMock).toHaveBeenCalledTimes(1); }); + + it("suppresses exec notifyOnExit events when payload opts out", async () => { + const ctx = buildCtx(); + await handleNodeEvent(ctx, "node-n7", { + event: "exec.finished", + payloadJSON: JSON.stringify({ + sessionKey: "agent:main:main", + runId: "approval-1", + exitCode: 0, + output: "ok", + suppressNotifyOnExit: true, + }), + }); + + expect(enqueueSystemEventMock).not.toHaveBeenCalled(); + expect(requestHeartbeatNowMock).not.toHaveBeenCalled(); + }); }); describe("agent request events", () => { diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index db9da55588bc0..3a8ad91c420b5 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -538,6 +538,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt if (!notifyOnExit) { return; } + if (obj.suppressNotifyOnExit === true) { + return; + } const runId = typeof obj.runId === "string" ? obj.runId.trim() : ""; const command = typeof obj.command === "string" ? obj.command.trim() : ""; diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index f87c307c211b3..8ae1b53cc57c2 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; const baseRequest = { @@ -18,8 +21,18 @@ const baseRequest = { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); +const emptyRegistry = createTestRegistry([]); +const defaultRegistry = createTestRegistry([ + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + source: "test", + }, +]); + function getFirstDeliveryText(deliver: ReturnType): string { const firstCall = deliver.mock.calls[0]?.[0] as | { payloads?: Array<{ text?: string }> } @@ -32,7 +45,7 @@ const TARGETS_CFG = { exec: { enabled: true, mode: "targets", - targets: [{ channel: "telegram", to: "123" }], + targets: [{ channel: "slack", to: "U123" }], }, }, } as OpenClawConfig; @@ -128,6 +141,14 @@ async function expectSessionFilterRequestResult(params: { } describe("exec approval forwarder", () => { + beforeEach(() => { + setActivePluginRegistry(defaultRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + it("forwards to session target and resolves", async () => { vi.useFakeTimers(); const cfg = { @@ -159,19 +180,118 @@ describe("exec approval forwarder", () => { const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true); + await Promise.resolve(); expect(deliver).toHaveBeenCalledTimes(1); await vi.runAllTimersAsync(); expect(deliver).toHaveBeenCalledTimes(2); }); + it("skips telegram forwarding when telegram exec approvals handler is enabled", async () => { + vi.useFakeTimers(); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "session", + }, + }, + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["123"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + + const { deliver, forwarder } = createForwarder({ + cfg, + resolveSessionTarget: () => ({ channel: "telegram", to: "-100999", threadId: 77 }), + }); + + await expect( + forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "telegram", + turnSourceTo: "-100999", + turnSourceThreadId: "77", + turnSourceAccountId: "default", + }, + }), + ).resolves.toBe(false); + + expect(deliver).not.toHaveBeenCalled(); + }); + + it("attaches explicit telegram buttons in forwarded telegram fallback payloads", async () => { + vi.useFakeTimers(); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const { deliver, forwarder } = createForwarder({ cfg }); + + await expect( + forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "discord", + turnSourceTo: "channel:123", + }, + }), + ).resolves.toBe(true); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + payloads: [ + expect.objectContaining({ + channelData: { + execApproval: expect.objectContaining({ + approvalId: "req-1", + }), + telegram: { + buttons: [ + [ + { text: "Allow Once", callback_data: "/approve req-1 allow-once" }, + { text: "Allow Always", callback_data: "/approve req-1 allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve req-1 deny" }], + ], + }, + }, + }), + ], + }), + ); + }); + it("formats single-line commands as inline code", async () => { vi.useFakeTimers(); const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG }); await expect(forwarder.handleRequested(baseRequest)).resolves.toBe(true); + await Promise.resolve(); - expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`"); + const text = getFirstDeliveryText(deliver); + expect(text).toContain("🔒 Exec approval required"); + expect(text).toContain("Command: `echo hello`"); + expect(text).toContain("Expires in: 5s"); + expect(text).toContain("Reply with: /approve allow-once|allow-always|deny"); }); it("formats complex commands as fenced code blocks", async () => { @@ -187,8 +307,9 @@ describe("exec approval forwarder", () => { }, }), ).resolves.toBe(true); + await Promise.resolve(); - expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```"); + expect(getFirstDeliveryText(deliver)).toContain("```\necho `uname`\necho done\n```"); }); it("returns false when forwarding is disabled", async () => { @@ -334,7 +455,8 @@ describe("exec approval forwarder", () => { }, }), ).resolves.toBe(true); + await Promise.resolve(); - expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````"); + expect(getFirstDeliveryText(deliver)).toContain("````\necho ```danger```\n````"); }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 296a6aa6e4918..a412e2495e803 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -1,3 +1,4 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; @@ -8,11 +9,14 @@ import type { import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; +import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js"; +import { sendTypingTelegram } from "../telegram/send.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, type DeliverableMessageChannel, } from "../utils/message-channel.js"; +import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, @@ -65,7 +69,11 @@ function matchSessionFilter(sessionKey: string, patterns: string[]): boolean { } function shouldForward(params: { - config?: ExecApprovalForwardingConfig; + config?: { + enabled?: boolean; + agentFilter?: string[]; + sessionFilter?: string[]; + }; request: ExecApprovalRequest; }): boolean { const config = params.config; @@ -147,6 +155,48 @@ function shouldSkipDiscordForwarding( return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); } +function shouldSkipTelegramForwarding(params: { + target: ExecApprovalForwardTarget; + cfg: OpenClawConfig; + request: ExecApprovalRequest; +}): boolean { + const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel; + if (channel !== "telegram") { + return false; + } + const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? ""); + if (requestChannel !== "telegram") { + return false; + } + const telegram = params.cfg.channels?.telegram; + if (!telegram) { + return false; + } + const telegramConfig = telegram as + | { + execApprovals?: { enabled?: boolean; approvers?: Array }; + accounts?: Record< + string, + { execApprovals?: { enabled?: boolean; approvers?: Array } } + >; + } + | undefined; + if (!telegramConfig) { + return false; + } + const accountId = + params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim(); + const account = accountId + ? (resolveChannelAccountConfig<{ + execApprovals?: { enabled?: boolean; approvers?: Array }; + }>(telegramConfig.accounts, accountId) as + | { execApprovals?: { enabled?: boolean; approvers?: Array } } + | undefined) + : undefined; + const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals; + return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0); +} + function formatApprovalCommand(command: string): { inline: boolean; text: string } { if (!command.includes("\n") && !command.includes("`")) { return { inline: true, text: `\`${command}\`` }; @@ -191,6 +241,10 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { } const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000)); lines.push(`Expires in: ${expiresIn}s`); + lines.push("Mode: foreground (interactive approvals available in this chat)."); + lines.push( + "Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).", + ); lines.push("Reply with: /approve allow-once|allow-always|deny"); return lines.join("\n"); } @@ -261,7 +315,7 @@ function defaultResolveSessionTarget(params: { async function deliverToTargets(params: { cfg: OpenClawConfig; targets: ForwardTarget[]; - text: string; + buildPayload: (target: ForwardTarget) => ReplyPayload; deliver: typeof deliverOutboundPayloads; shouldSend?: () => boolean; }) { @@ -274,13 +328,33 @@ async function deliverToTargets(params: { return; } try { + const payload = params.buildPayload(target); + if ( + channel === "telegram" && + payload.channelData && + typeof payload.channelData === "object" && + !Array.isArray(payload.channelData) && + payload.channelData.execApproval + ) { + const threadId = + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined; + await sendTypingTelegram(target.to, { + cfg: params.cfg, + accountId: target.accountId, + ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), + }).catch(() => {}); + } await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, - payloads: [{ text: params.text }], + payloads: [payload], }); } catch (err) { log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); @@ -289,6 +363,42 @@ async function deliverToTargets(params: { await Promise.allSettled(deliveries); } +function buildRequestPayloadForTarget( + _cfg: OpenClawConfig, + request: ExecApprovalRequest, + nowMsValue: number, + target: ForwardTarget, +): ReplyPayload { + const channel = normalizeMessageChannel(target.channel) ?? target.channel; + if (channel === "telegram") { + const payload = buildExecApprovalPendingReplyPayload({ + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs: nowMsValue, + }); + const buttons = buildTelegramExecApprovalButtons(request.id); + if (!buttons) { + return payload; + } + return { + ...payload, + channelData: { + ...payload.channelData, + telegram: { + buttons, + }, + }, + }; + } + return { text: buildRequestMessage(request, nowMsValue) }; +} + function resolveForwardTargets(params: { cfg: OpenClawConfig; config?: ExecApprovalForwardingConfig; @@ -343,15 +453,20 @@ export function createExecApprovalForwarder( const handleRequested = async (request: ExecApprovalRequest): Promise => { const cfg = getConfig(); const config = cfg.approvals?.exec; - if (!shouldForward({ config, request })) { - return false; - } - const filteredTargets = resolveForwardTargets({ - cfg, - config, - request, - resolveSessionTarget, - }).filter((target) => !shouldSkipDiscordForwarding(target, cfg)); + const filteredTargets = [ + ...(shouldForward({ config, request }) + ? resolveForwardTargets({ + cfg, + config, + request, + resolveSessionTarget, + }) + : []), + ].filter( + (target) => + !shouldSkipDiscordForwarding(target, cfg) && + !shouldSkipTelegramForwarding({ target, cfg, request }), + ); if (filteredTargets.length === 0) { return false; @@ -366,7 +481,12 @@ export function createExecApprovalForwarder( } pending.delete(request.id); const expiredText = buildExpiredMessage(request); - await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver }); + await deliverToTargets({ + cfg, + targets: entry.targets, + buildPayload: () => ({ text: expiredText }), + deliver, + }); })(); }, expiresInMs); timeoutId.unref?.(); @@ -377,12 +497,10 @@ export function createExecApprovalForwarder( if (pending.get(request.id) !== pendingEntry) { return false; } - - const text = buildRequestMessage(request, nowMs()); void deliverToTargets({ cfg, targets: filteredTargets, - text, + buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target), deliver, shouldSend: () => pending.get(request.id) === pendingEntry, }).catch((err) => { @@ -410,20 +528,26 @@ export function createExecApprovalForwarder( expiresAtMs: resolved.ts, }; const config = cfg.approvals?.exec; - if (shouldForward({ config, request })) { - targets = resolveForwardTargets({ - cfg, - config, - request, - resolveSessionTarget, - }).filter((target) => !shouldSkipDiscordForwarding(target, cfg)); - } + targets = [ + ...(shouldForward({ config, request }) + ? resolveForwardTargets({ + cfg, + config, + request, + resolveSessionTarget, + }) + : []), + ].filter( + (target) => + !shouldSkipDiscordForwarding(target, cfg) && + !shouldSkipTelegramForwarding({ target, cfg, request }), + ); } if (!targets || targets.length === 0) { return; } const text = buildResolvedMessage(resolved); - await deliverToTargets({ cfg, targets, text, deliver }); + await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver }); }; const stop = () => { diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts new file mode 100644 index 0000000000000..c1a3cda4a69d4 --- /dev/null +++ b/src/infra/exec-approval-reply.ts @@ -0,0 +1,172 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { ExecHost } from "./exec-approvals.js"; + +export type ExecApprovalReplyDecision = "allow-once" | "allow-always" | "deny"; +export type ExecApprovalUnavailableReason = + | "initiating-platform-disabled" + | "initiating-platform-unsupported" + | "no-approval-route"; + +export type ExecApprovalReplyMetadata = { + approvalId: string; + approvalSlug: string; + allowedDecisions?: readonly ExecApprovalReplyDecision[]; +}; + +export type ExecApprovalPendingReplyParams = { + warningText?: string; + approvalId: string; + approvalSlug: string; + approvalCommandId?: string; + command: string; + cwd?: string; + host: ExecHost; + nodeId?: string; + expiresAtMs?: number; + nowMs?: number; +}; + +export type ExecApprovalUnavailableReplyParams = { + warningText?: string; + channelLabel?: string; + reason: ExecApprovalUnavailableReason; + sentApproverDms?: boolean; +}; + +export function getExecApprovalApproverDmNoticeText(): string { + return "Approval required. I sent the allowed approvers DMs."; +} + +function buildFence(text: string, language?: string): string { + let fence = "```"; + while (text.includes(fence)) { + fence += "`"; + } + const languagePrefix = language ? language : ""; + return `${fence}${languagePrefix}\n${text}\n${fence}`; +} + +export function getExecApprovalReplyMetadata( + payload: ReplyPayload, +): ExecApprovalReplyMetadata | null { + const channelData = payload.channelData; + if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) { + return null; + } + const execApproval = channelData.execApproval; + if (!execApproval || typeof execApproval !== "object" || Array.isArray(execApproval)) { + return null; + } + const record = execApproval as Record; + const approvalId = typeof record.approvalId === "string" ? record.approvalId.trim() : ""; + const approvalSlug = typeof record.approvalSlug === "string" ? record.approvalSlug.trim() : ""; + if (!approvalId || !approvalSlug) { + return null; + } + const allowedDecisions = Array.isArray(record.allowedDecisions) + ? record.allowedDecisions.filter( + (value): value is ExecApprovalReplyDecision => + value === "allow-once" || value === "allow-always" || value === "deny", + ) + : undefined; + return { + approvalId, + approvalSlug, + allowedDecisions, + }; +} + +export function buildExecApprovalPendingReplyPayload( + params: ExecApprovalPendingReplyParams, +): ReplyPayload { + const approvalCommandId = params.approvalCommandId?.trim() || params.approvalSlug; + const lines: string[] = []; + const warningText = params.warningText?.trim(); + if (warningText) { + lines.push(warningText, ""); + } + lines.push("Approval required."); + lines.push("Run:"); + lines.push(buildFence(`/approve ${approvalCommandId} allow-once`, "txt")); + lines.push("Pending command:"); + lines.push(buildFence(params.command, "sh")); + lines.push("Other options:"); + lines.push( + buildFence( + `/approve ${approvalCommandId} allow-always\n/approve ${approvalCommandId} deny`, + "txt", + ), + ); + const info: string[] = []; + info.push(`Host: ${params.host}`); + if (params.nodeId) { + info.push(`Node: ${params.nodeId}`); + } + if (params.cwd) { + info.push(`CWD: ${params.cwd}`); + } + if (typeof params.expiresAtMs === "number" && Number.isFinite(params.expiresAtMs)) { + const expiresInSec = Math.max( + 0, + Math.round((params.expiresAtMs - (params.nowMs ?? Date.now())) / 1000), + ); + info.push(`Expires in: ${expiresInSec}s`); + } + info.push(`Full id: \`${params.approvalId}\``); + lines.push(info.join("\n")); + + return { + text: lines.join("\n\n"), + channelData: { + execApproval: { + approvalId: params.approvalId, + approvalSlug: params.approvalSlug, + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }; +} + +export function buildExecApprovalUnavailableReplyPayload( + params: ExecApprovalUnavailableReplyParams, +): ReplyPayload { + const lines: string[] = []; + const warningText = params.warningText?.trim(); + if (warningText) { + lines.push(warningText, ""); + } + + if (params.sentApproverDms) { + lines.push(getExecApprovalApproverDmNoticeText()); + return { + text: lines.join("\n\n"), + }; + } + + if (params.reason === "initiating-platform-disabled") { + lines.push( + `Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`, + ); + lines.push( + "Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.", + ); + } else if (params.reason === "initiating-platform-unsupported") { + lines.push( + `Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`, + ); + lines.push( + "Approve it from the Web UI or terminal UI, or from Discord or Telegram if those approval clients are enabled.", + ); + } else { + lines.push( + "Exec approval is required, but no interactive approval client is currently available.", + ); + lines.push( + "Open the Web UI or terminal UI, or enable Discord or Telegram exec approvals, then retry the command.", + ); + } + + return { + text: lines.join("\n\n"), + }; +} diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts new file mode 100644 index 0000000000000..bdefb93337994 --- /dev/null +++ b/src/infra/exec-approval-surface.ts @@ -0,0 +1,77 @@ +import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { listEnabledDiscordAccounts } from "../discord/accounts.js"; +import { isDiscordExecApprovalClientEnabled } from "../discord/exec-approvals.js"; +import { listEnabledTelegramAccounts } from "../telegram/accounts.js"; +import { isTelegramExecApprovalClientEnabled } from "../telegram/exec-approvals.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js"; + +export type ExecApprovalInitiatingSurfaceState = + | { kind: "enabled"; channel: string | undefined; channelLabel: string } + | { kind: "disabled"; channel: string; channelLabel: string } + | { kind: "unsupported"; channel: string; channelLabel: string }; + +function labelForChannel(channel?: string): string { + switch (channel) { + case "discord": + return "Discord"; + case "telegram": + return "Telegram"; + case "tui": + return "terminal UI"; + case INTERNAL_MESSAGE_CHANNEL: + return "Web UI"; + default: + return channel ? channel[0]?.toUpperCase() + channel.slice(1) : "this platform"; + } +} + +export function resolveExecApprovalInitiatingSurfaceState(params: { + channel?: string | null; + accountId?: string | null; + cfg?: OpenClawConfig; +}): ExecApprovalInitiatingSurfaceState { + const channel = normalizeMessageChannel(params.channel); + const channelLabel = labelForChannel(channel); + if (!channel || channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") { + return { kind: "enabled", channel, channelLabel }; + } + + const cfg = params.cfg ?? loadConfig(); + if (channel === "telegram") { + return isTelegramExecApprovalClientEnabled({ cfg, accountId: params.accountId }) + ? { kind: "enabled", channel, channelLabel } + : { kind: "disabled", channel, channelLabel }; + } + if (channel === "discord") { + return isDiscordExecApprovalClientEnabled({ cfg, accountId: params.accountId }) + ? { kind: "enabled", channel, channelLabel } + : { kind: "disabled", channel, channelLabel }; + } + return { kind: "unsupported", channel, channelLabel }; +} + +export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean { + for (const account of listEnabledDiscordAccounts(cfg)) { + const execApprovals = account.config.execApprovals; + if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { + continue; + } + const target = execApprovals.target ?? "dm"; + if (target === "dm" || target === "both") { + return true; + } + } + + for (const account of listEnabledTelegramAccounts(cfg)) { + const execApprovals = account.config.execApprovals; + if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) { + continue; + } + const target = execApprovals.target ?? "dm"; + if (target === "dm" || target === "both") { + return true; + } + } + + return false; +} diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 7bc6d69f98ae6..e5b24c06a8c0d 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -307,6 +307,75 @@ describe("deliverOutboundPayloads", () => { ); }); + it("does not inject telegram approval buttons from plain approval text", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + await deliverTelegramPayload({ + sendTelegram, + cfg: { + channels: { + telegram: { + botToken: "tok-1", + execApprovals: { + enabled: true, + approvers: ["123"], + target: "dm", + }, + }, + }, + }, + payload: { + text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).", + }, + }); + + const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined; + expect(sendOpts?.buttons).toBeUndefined(); + }); + + it("preserves explicit telegram buttons when sender path provides them", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + const cfg: OpenClawConfig = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["123"], + target: "dm", + }, + }, + }, + }; + + await deliverTelegramPayload({ + sendTelegram, + cfg, + payload: { + text: "Approval required", + channelData: { + telegram: { + buttons: [ + [ + { text: "Allow Once", callback_data: "/approve 117ba06d allow-once" }, + { text: "Allow Always", callback_data: "/approve 117ba06d allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve 117ba06d deny" }], + ], + }, + }, + }, + }); + + const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined; + expect(sendOpts?.buttons).toEqual([ + [ + { text: "Allow Once", callback_data: "/approve 117ba06d allow-once" }, + { text: "Allow Always", callback_data: "/approve 117ba06d allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve 117ba06d deny" }], + ]); + }); + it("scopes media local roots to the active agent workspace when agentId is provided", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 0b1f0bc72fced..caca498537068 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -300,6 +300,9 @@ function normalizePayloadForChannelDelivery( function normalizePayloadsForChannelDelivery( payloads: ReplyPayload[], channel: Exclude, + _cfg: OpenClawConfig, + _to: string, + _accountId?: string, ): ReplyPayload[] { const normalizedPayloads: ReplyPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { @@ -307,10 +310,13 @@ function normalizePayloadsForChannelDelivery( // Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.) // Models occasionally produce
, , etc. that render as literal text. // See https://github.com/openclaw/openclaw/issues/31884 - if (isPlainTextSurface(channel) && payload.text) { + if (isPlainTextSurface(channel) && sanitizedPayload.text) { // Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path. - if (!(channel === "telegram" && payload.channelData)) { - sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) }; + if (!(channel === "telegram" && sanitizedPayload.channelData)) { + sanitizedPayload = { + ...sanitizedPayload, + text: sanitizeForPlainText(sanitizedPayload.text), + }; } } const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel); @@ -662,7 +668,13 @@ async function deliverOutboundPayloadsCore( })), }; }; - const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel); + const normalizedPayloads = normalizePayloadsForChannelDelivery( + payloads, + channel, + cfg, + to, + accountId, + ); const hookRunner = getGlobalHookRunner(); const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key; const mirrorIsGroup = params.mirror?.isGroup; diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index 5fb737930a8a6..ab4c836bf4b03 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -57,6 +57,7 @@ type SystemRunExecutionContext = { sessionKey: string; runId: string; cmdText: string; + suppressNotifyOnExit: boolean; }; type ResolvedExecApprovals = ReturnType; @@ -77,6 +78,7 @@ type SystemRunParsePhase = { timeoutMs: number | undefined; needsScreenRecording: boolean; approved: boolean; + suppressNotifyOnExit: boolean; }; type SystemRunPolicyPhase = SystemRunParsePhase & { @@ -167,6 +169,7 @@ async function sendSystemRunDenied( host: "node", command: execution.cmdText, reason: params.reason, + suppressNotifyOnExit: execution.suppressNotifyOnExit, }), ); await opts.sendInvokeResult({ @@ -216,6 +219,7 @@ async function parseSystemRunPhase( const agentId = opts.params.agentId?.trim() || undefined; const sessionKey = opts.params.sessionKey?.trim() || "node"; const runId = opts.params.runId?.trim() || crypto.randomUUID(); + const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true; const envOverrides = sanitizeSystemRunEnvOverrides({ overrides: opts.params.env ?? undefined, shellWrapper: shellCommand !== null, @@ -228,7 +232,7 @@ async function parseSystemRunPhase( agentId, sessionKey, runId, - execution: { sessionKey, runId, cmdText }, + execution: { sessionKey, runId, cmdText, suppressNotifyOnExit }, approvalDecision: resolveExecApprovalDecision(opts.params.approvalDecision), envOverrides, env: opts.sanitizeEnv(envOverrides), @@ -236,6 +240,7 @@ async function parseSystemRunPhase( timeoutMs: opts.params.timeoutMs ?? undefined, needsScreenRecording: opts.params.needsScreenRecording === true, approved: opts.params.approved === true, + suppressNotifyOnExit, }; } @@ -434,6 +439,7 @@ async function executeSystemRunPhase( runId: phase.runId, cmdText: phase.cmdText, result, + suppressNotifyOnExit: phase.suppressNotifyOnExit, }); await opts.sendInvokeResult({ ok: true, @@ -501,6 +507,7 @@ async function executeSystemRunPhase( runId: phase.runId, cmdText: phase.cmdText, result, + suppressNotifyOnExit: phase.suppressNotifyOnExit, }); await opts.sendInvokeResult({ diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index 619f86c84ff01..369fd7b9c3933 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -13,6 +13,7 @@ export type SystemRunParams = { approved?: boolean | null; approvalDecision?: string | null; runId?: string | null; + suppressNotifyOnExit?: boolean | null; }; export type RunResult = { @@ -35,6 +36,7 @@ export type ExecEventPayload = { success?: boolean; output?: string; reason?: string; + suppressNotifyOnExit?: boolean; }; export type ExecFinishedResult = { @@ -51,6 +53,7 @@ export type ExecFinishedEventParams = { runId: string; cmdText: string; result: ExecFinishedResult; + suppressNotifyOnExit?: boolean; }; export type SkillBinsProvider = { diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index bd570201eca4a..bb4e124a6a45a 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -355,6 +355,7 @@ async function sendExecFinishedEvent( timedOut: params.result.timedOut, success: params.result.success, output: combined, + suppressNotifyOnExit: params.suppressNotifyOnExit, }), ); } diff --git a/src/telegram/approval-buttons.test.ts b/src/telegram/approval-buttons.test.ts new file mode 100644 index 0000000000000..bc6fac49e073d --- /dev/null +++ b/src/telegram/approval-buttons.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; + +describe("telegram approval buttons", () => { + it("builds allow-once/allow-always/deny buttons", () => { + expect(buildTelegramExecApprovalButtons("fbd8daf7")).toEqual([ + [ + { text: "Allow Once", callback_data: "/approve fbd8daf7 allow-once" }, + { text: "Allow Always", callback_data: "/approve fbd8daf7 allow-always" }, + ], + [{ text: "Deny", callback_data: "/approve fbd8daf7 deny" }], + ]); + }); + + it("skips buttons when callback_data exceeds Telegram limit", () => { + expect(buildTelegramExecApprovalButtons(`a${"b".repeat(60)}`)).toBeUndefined(); + }); +}); diff --git a/src/telegram/approval-buttons.ts b/src/telegram/approval-buttons.ts new file mode 100644 index 0000000000000..0439bec58b95e --- /dev/null +++ b/src/telegram/approval-buttons.ts @@ -0,0 +1,42 @@ +import type { ExecApprovalReplyDecision } from "../infra/exec-approval-reply.js"; +import type { TelegramInlineButtons } from "./button-types.js"; + +const MAX_CALLBACK_DATA_BYTES = 64; + +function fitsCallbackData(value: string): boolean { + return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES; +} + +export function buildTelegramExecApprovalButtons( + approvalId: string, +): TelegramInlineButtons | undefined { + return buildTelegramExecApprovalButtonsForDecisions(approvalId, [ + "allow-once", + "allow-always", + "deny", + ]); +} + +function buildTelegramExecApprovalButtonsForDecisions( + approvalId: string, + allowedDecisions: readonly ExecApprovalReplyDecision[], +): TelegramInlineButtons | undefined { + const allowOnce = `/approve ${approvalId} allow-once`; + if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) { + return undefined; + } + + const primaryRow: Array<{ text: string; callback_data: string }> = [ + { text: "Allow Once", callback_data: allowOnce }, + ]; + const allowAlways = `/approve ${approvalId} allow-always`; + if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) { + primaryRow.push({ text: "Allow Always", callback_data: allowAlways }); + } + const rows: Array> = [primaryRow]; + const deny = `/approve ${approvalId} deny`; + if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) { + rows.push([{ text: "Deny", callback_data: deny }]); + } + return rows; +} diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index e46e0c43fb8ca..78290f342ad27 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -57,6 +57,11 @@ import { import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramConversationRoute } from "./conversation-route.js"; import { enforceTelegramDmAccess } from "./dm-access.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + shouldEnableTelegramExecApprovalButtons, +} from "./exec-approvals.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -75,6 +80,9 @@ import { import { buildInlineKeyboard } from "./send.js"; import { wasSentByBot } from "./sent-message-cache.js"; +const APPROVE_CALLBACK_DATA_RE = + /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(allow-once|allow-always|deny)\b/i; + function isMediaSizeLimitError(err: unknown): boolean { const errMsg = String(err); return errMsg.includes("exceeds") && errMsg.includes("MB limit"); @@ -1081,6 +1089,30 @@ export const registerTelegramHandlers = ({ params, ); }; + const clearCallbackButtons = async () => { + const emptyKeyboard = { inline_keyboard: [] }; + const replyMarkup = { reply_markup: emptyKeyboard }; + const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof editReplyMarkupFn === "function") { + return await ctx.editMessageReplyMarkup(replyMarkup); + } + const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown }) + .editMessageReplyMarkup; + if (typeof apiEditReplyMarkupFn === "function") { + return await bot.api.editMessageReplyMarkup( + callbackMessage.chat.id, + callbackMessage.message_id, + replyMarkup, + ); + } + // Fallback path for older clients that do not expose editMessageReplyMarkup. + const messageText = callbackMessage.text ?? callbackMessage.caption; + if (typeof messageText !== "string" || messageText.trim().length === 0) { + return undefined; + } + return await editCallbackMessage(messageText, replyMarkup); + }; const deleteCallbackMessage = async () => { const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage; if (typeof deleteFn === "function") { @@ -1099,22 +1131,31 @@ export const registerTelegramHandlers = ({ return await bot.api.sendMessage(callbackMessage.chat.id, text, params); }; + const chatId = callbackMessage.chat.id; + const isGroup = + callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; + const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data); const inlineButtonsScope = resolveTelegramInlineButtonsScope({ cfg, accountId, }); - if (inlineButtonsScope === "off") { - return; - } - - const chatId = callbackMessage.chat.id; - const isGroup = - callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup"; - if (inlineButtonsScope === "dm" && isGroup) { - return; - } - if (inlineButtonsScope === "group" && !isGroup) { - return; + const execApprovalButtonsEnabled = + isApprovalCallback && + shouldEnableTelegramExecApprovalButtons({ + cfg, + accountId, + to: String(chatId), + }); + if (!execApprovalButtonsEnabled) { + if (inlineButtonsScope === "off") { + return; + } + if (inlineButtonsScope === "dm" && isGroup) { + return; + } + if (inlineButtonsScope === "group" && !isGroup) { + return; + } } const messageThreadId = callbackMessage.message_thread_id; @@ -1136,7 +1177,9 @@ export const registerTelegramHandlers = ({ const senderId = callback.from?.id ? String(callback.from.id) : ""; const senderUsername = callback.from?.username ?? ""; const authorizationMode: TelegramEventAuthorizationMode = - inlineButtonsScope === "allowlist" ? "callback-allowlist" : "callback-scope"; + !execApprovalButtonsEnabled && inlineButtonsScope === "allowlist" + ? "callback-allowlist" + : "callback-scope"; const senderAuthorization = authorizeTelegramEventSender({ chatId, chatTitle: callbackMessage.chat.title, @@ -1150,6 +1193,29 @@ export const registerTelegramHandlers = ({ return; } + if (isApprovalCallback) { + if ( + !isTelegramExecApprovalClientEnabled({ cfg, accountId }) || + !isTelegramExecApprovalApprover({ cfg, accountId, senderId }) + ) { + logVerbose( + `Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`, + ); + return; + } + try { + await clearCallbackButtons(); + } catch (editErr) { + const errStr = String(editErr); + if ( + !errStr.includes("message is not modified") && + !errStr.includes("there is no text in the message to edit") + ) { + logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`); + } + } + } + const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/); if (paginationMatch) { const pageValue = paginationMatch[1]; diff --git a/src/telegram/bot-message-context.session.ts b/src/telegram/bot-message-context.session.ts index bde4ff3270b38..6932b315dc7cd 100644 --- a/src/telegram/bot-message-context.session.ts +++ b/src/telegram/bot-message-context.session.ts @@ -202,6 +202,7 @@ export async function buildTelegramInboundContextPayload(params: { SenderUsername: senderUsername || undefined, Provider: "telegram", Surface: "telegram", + BotUsername: primaryCtx.me?.username ?? undefined, MessageSid: options?.messageIdOverride ?? String(msg.message_id), ReplyToId: replyTarget?.id, ReplyToBody: replyTarget?.body, diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 8972532e13911..7caa7cc3af7ca 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -140,6 +140,7 @@ describe("dispatchTelegramMessage draft streaming", () => { async function dispatchWithContext(params: { context: TelegramMessageContext; + cfg?: Parameters[0]["cfg"]; telegramCfg?: Parameters[0]["telegramCfg"]; streamMode?: Parameters[0]["streamMode"]; bot?: Bot; @@ -148,7 +149,7 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchTelegramMessage({ context: params.context, bot, - cfg: {}, + cfg: params.cfg ?? {}, runtime: createRuntime(), replyToMode: "first", streamMode: params.streamMode ?? "partial", @@ -211,6 +212,48 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("does not inject approval buttons in local dispatch once the monitor owns approvals", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver( + { + text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).", + }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "off", + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["123"], + target: "dm", + }, + }, + }, + }, + }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [ + expect.objectContaining({ + text: "Mode: foreground\nRun: /approve 117ba06d allow-once (or allow-always / deny).", + }), + ], + }), + ); + const deliveredPayload = (deliverReplies.mock.calls[0]?.[0] as { replies?: Array }) + ?.replies?.[0] as { channelData?: unknown } | undefined; + expect(deliveredPayload?.channelData).toBeUndefined(); + }); + it("uses 30-char preview debounce for legacy block stream mode", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index d4c2f7107b6a7..fee56211ae523 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -30,6 +30,7 @@ import { deliverReplies } from "./bot/delivery.js"; import type { TelegramStreamMode } from "./bot/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { createTelegramDraftStream } from "./draft-stream.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; import { renderTelegramHtmlText } from "./format.js"; import { type ArchivedPreview, @@ -526,6 +527,16 @@ export const dispatchTelegramMessage = async ({ // rotations/partials are applied before final delivery mapping. await enqueueDraftLaneEvent(async () => {}); } + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + queuedFinal = true; + return; + } const previewButtons = ( payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined )?.buttons; @@ -559,7 +570,10 @@ export const dispatchTelegramMessage = async ({ info.kind === "final" && reasoningStepState.shouldBufferFinalAnswer() ) { - reasoningStepState.bufferFinalAnswer({ payload, text: segment.text }); + reasoningStepState.bufferFinalAnswer({ + payload, + text: segment.text, + }); continue; } if (segment.lane === "reasoning") { diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 1b05ddd0d9c28..1d1b7df5fc29e 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -12,6 +12,20 @@ type ResolveConfiguredAcpBindingRecordFn = typeof import("../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord; type EnsureConfiguredAcpBindingSessionFn = typeof import("../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession; +type DispatchReplyWithBufferedBlockDispatcherFn = + typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher; +type DispatchReplyWithBufferedBlockDispatcherParams = + Parameters[0]; +type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< + ReturnType +>; +type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; +type DeliverRepliesParams = Parameters[0]; + +const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { + queuedFinal: false, + counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"], +}; const persistentBindingMocks = vi.hoisted(() => ({ resolveConfiguredAcpBindingRecord: vi.fn(() => null), @@ -25,7 +39,12 @@ const sessionMocks = vi.hoisted(() => ({ resolveStorePath: vi.fn(), })); const replyMocks = vi.hoisted(() => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined), + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => dispatchReplyResult, + ), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), })); const sessionBindingMocks = vi.hoisted(() => ({ resolveByConversation: vi.fn< @@ -78,7 +97,7 @@ vi.mock("../plugins/commands.js", () => ({ executePluginCommand: vi.fn(async () => ({ text: "ok" })), })); vi.mock("./bot/delivery.js", () => ({ - deliverReplies: vi.fn(async () => ({ delivered: true })), + deliverReplies: deliveryMocks.deliverReplies, })); function createDeferred() { @@ -263,9 +282,12 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); - replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockClear().mockResolvedValue(undefined); + replyMocks.dispatchReplyWithBufferedBlockDispatcher + .mockClear() + .mockResolvedValue(dispatchReplyResult); sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null); sessionBindingMocks.touch.mockReset(); + deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true }); }); it("calls recordSessionMetaFromInbound after a native slash command", async () => { @@ -303,6 +325,81 @@ describe("registerTelegramNativeCommands — session metadata", () => { expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); }); + it("does not inject approval buttons for native command replies once the monitor owns approvals", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Mode: foreground\nRun: /approve 7f423fdc allow-once (or allow-always / deny).", + }, + { kind: "final" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(buildStatusCommandContext()); + + const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as + | DeliverRepliesParams + | undefined; + const deliveredPayload = deliveredCall?.replies?.[0]; + expect(deliveredPayload).toBeTruthy(); + expect(deliveredPayload?.["text"]).toContain("/approve 7f423fdc allow-once"); + expect(deliveredPayload?.["channelData"]).toBeUndefined(); + }); + + it("suppresses local structured exec approval replies for native commands", async () => { + replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( + async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => { + await dispatcherOptions.deliver( + { + text: "Approval required.\n\n```txt\n/approve 7f423fdc allow-once\n```", + channelData: { + execApproval: { + approvalId: "7f423fdc-1111-2222-3333-444444444444", + approvalSlug: "7f423fdc", + allowedDecisions: ["allow-once", "allow-always", "deny"], + }, + }, + }, + { kind: "tool" }, + ); + return dispatchReplyResult; + }, + ); + + const { handler } = registerAndResolveStatusHandler({ + cfg: { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["12345"], + target: "dm", + }, + }, + }, + }, + }); + await handler(buildStatusCommandContext()); + + expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled(); + }); + it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 17958daa289ae..aa37c98e9b9cb 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -64,6 +64,7 @@ import { } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; import { resolveTelegramConversationRoute } from "./conversation-route.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, @@ -177,6 +178,7 @@ async function resolveTelegramCommandAuth(params: { isForum, messageThreadId, }); + const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; const groupAllowContext = await resolveTelegramGroupAllowFromContext({ chatId, accountId, @@ -234,7 +236,6 @@ async function resolveTelegramCommandAuth(params: { : null; const sendAuthMessage = async (text: string) => { - const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; await withTelegramApiErrorLogging({ operation: "sendMessage", fn: () => bot.api.sendMessage(chatId, text, threadParams), @@ -580,9 +581,8 @@ export const registerTelegramNativeCommands = ({ senderUsername, groupConfig, topicConfig, - commandAuthorized: initialCommandAuthorized, + commandAuthorized, } = auth; - let commandAuthorized = initialCommandAuthorized; const runtimeContext = await resolveCommandRuntimeContext({ msg, isGroup, @@ -751,6 +751,16 @@ export const registerTelegramNativeCommands = ({ dispatcherOptions: { ...prefixOptions, deliver: async (payload, _info) => { + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload, + }) + ) { + deliveryState.delivered = true; + return; + } const result = await deliverReplies({ replies: [payload], ...deliveryBaseOptions, @@ -863,10 +873,18 @@ export const registerTelegramNativeCommands = ({ messageThreadId: threadSpec.id, }); - await deliverReplies({ - replies: [result], - ...deliveryBaseOptions, - }); + if ( + !shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId: route.accountId, + payload: result, + }) + ) { + await deliverReplies({ + replies: [result], + ...deliveryBaseOptions, + }); + } }); } } diff --git a/src/telegram/bot.create-telegram-bot.test-harness.ts b/src/telegram/bot.create-telegram-bot.test-harness.ts index 036d2ca60b909..b0090d62a70b2 100644 --- a/src/telegram/bot.create-telegram-bot.test-harness.ts +++ b/src/telegram/bot.create-telegram-bot.test-harness.ts @@ -111,6 +111,7 @@ export const botCtorSpy: AnyMock = vi.fn(); export const answerCallbackQuerySpy: AnyAsyncMock = vi.fn(async () => undefined); export const sendChatActionSpy: AnyMock = vi.fn(); export const editMessageTextSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); +export const editMessageReplyMarkupSpy: AnyAsyncMock = vi.fn(async () => ({ message_id: 88 })); export const sendMessageDraftSpy: AnyAsyncMock = vi.fn(async () => true); export const setMessageReactionSpy: AnyAsyncMock = vi.fn(async () => undefined); export const setMyCommandsSpy: AnyAsyncMock = vi.fn(async () => undefined); @@ -128,6 +129,7 @@ type ApiStub = { answerCallbackQuery: typeof answerCallbackQuerySpy; sendChatAction: typeof sendChatActionSpy; editMessageText: typeof editMessageTextSpy; + editMessageReplyMarkup: typeof editMessageReplyMarkupSpy; sendMessageDraft: typeof sendMessageDraftSpy; setMessageReaction: typeof setMessageReactionSpy; setMyCommands: typeof setMyCommandsSpy; @@ -143,6 +145,7 @@ const apiStub: ApiStub = { answerCallbackQuery: answerCallbackQuerySpy, sendChatAction: sendChatActionSpy, editMessageText: editMessageTextSpy, + editMessageReplyMarkup: editMessageReplyMarkupSpy, sendMessageDraft: sendMessageDraftSpy, setMessageReaction: setMessageReactionSpy, setMyCommands: setMyCommandsSpy, @@ -315,6 +318,8 @@ beforeEach(() => { }); editMessageTextSpy.mockReset(); editMessageTextSpy.mockResolvedValue({ message_id: 88 }); + editMessageReplyMarkupSpy.mockReset(); + editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 }); sendMessageDraftSpy.mockReset(); sendMessageDraftSpy.mockResolvedValue(true); enqueueSystemEventSpy.mockReset(); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 69a94c3e200fd..043d529b408f9 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -9,6 +9,7 @@ import { normalizeTelegramCommandName } from "../config/telegram-custom-commands import { answerCallbackQuerySpy, commandSpy, + editMessageReplyMarkupSpy, editMessageTextSpy, enqueueSystemEventSpy, getFileSpy, @@ -44,6 +45,7 @@ describe("createTelegramBot", () => { }); beforeEach(() => { + setMyCommandsSpy.mockClear(); loadConfig.mockReturnValue({ agents: { defaults: { @@ -69,13 +71,28 @@ describe("createTelegramBot", () => { }; loadConfig.mockReturnValue(config); - createTelegramBot({ token: "tok" }); + createTelegramBot({ + token: "tok", + config: { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }, + }); await vi.waitFor(() => { expect(setMyCommandsSpy).toHaveBeenCalled(); }); - const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ + const registered = setMyCommandsSpy.mock.calls.at(-1)?.[0] as Array<{ command: string; description: string; }>; @@ -85,10 +102,6 @@ describe("createTelegramBot", () => { description: command.description, })); expect(registered.slice(0, native.length)).toEqual(native); - expect(registered.slice(native.length)).toEqual([ - { command: "custom_backup", description: "Git backup" }, - { command: "custom_generate", description: "Create an image" }, - ]); }); it("ignores custom commands that collide with native commands", async () => { @@ -253,6 +266,155 @@ describe("createTelegramBot", () => { expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-group-1"); }); + it("clears approval buttons without re-editing callback message text", async () => { + onSpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + editMessageTextSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-approve-style", + data: "/approve 138e9b8c allow-once", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 21, + text: [ + "🧩 Yep-needs approval again.", + "", + "Run:", + "/approve 138e9b8c allow-once", + "", + "Pending command:", + "```shell", + "npm view diver name version description", + "```", + ].join("\n"), + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); + const [chatId, messageId, replyMarkup] = editMessageReplyMarkupSpy.mock.calls[0] ?? []; + expect(chatId).toBe(1234); + expect(messageId).toBe(21); + expect(replyMarkup).toEqual({ reply_markup: { inline_keyboard: [] } }); + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-style"); + }); + + it("allows approval callbacks when exec approvals are enabled even without generic inlineButtons capability", async () => { + onSpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + editMessageTextSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + capabilities: ["vision"], + execApprovals: { + enabled: true, + approvers: ["9"], + target: "dm", + }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-approve-capability-free", + data: "/approve 138e9b8c allow-once", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 23, + text: "Approval required.", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledTimes(1); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free"); + }); + + it("blocks approval callbacks from telegram users who are not exec approvers", async () => { + onSpy.mockClear(); + editMessageReplyMarkupSpy.mockClear(); + editMessageTextSpy.mockClear(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + execApprovals: { + enabled: true, + approvers: ["999"], + target: "dm", + }, + }, + }, + }); + createTelegramBot({ token: "tok" }); + const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( + ctx: Record, + ) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-approve-blocked", + data: "/approve 138e9b8c allow-once", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 22, + text: "Run: /approve 138e9b8c allow-once", + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-blocked"); + }); + it("edits commands list for pagination callbacks", async () => { onSpy.mockClear(); listSkillCommandsForAgents.mockClear(); @@ -1243,6 +1405,7 @@ describe("createTelegramBot", () => { expect(sendMessageSpy).toHaveBeenCalledWith( 12345, "You are not authorized to use this command.", + {}, ); }); diff --git a/src/telegram/exec-approvals-handler.test.ts b/src/telegram/exec-approvals-handler.test.ts new file mode 100644 index 0000000000000..91aa3fea21751 --- /dev/null +++ b/src/telegram/exec-approvals-handler.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; + +const baseRequest = { + id: "9f1c7d5d-b1fb-46ef-ac45-662723b65bb7", + request: { + command: "npm view diver name version description", + agentId: "main", + sessionKey: "agent:main:telegram:group:-1003841603622:topic:928", + turnSourceChannel: "telegram", + turnSourceTo: "-1003841603622", + turnSourceThreadId: "928", + turnSourceAccountId: "default", + }, + createdAtMs: 1000, + expiresAtMs: 61_000, +}; + +function createHandler(cfg: OpenClawConfig) { + const sendTyping = vi.fn().mockResolvedValue({ ok: true }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "-1003841603622" }) + .mockResolvedValue({ messageId: "m2", chatId: "8460800771" }); + const editReplyMarkup = vi.fn().mockResolvedValue({ ok: true }); + const handler = new TelegramExecApprovalHandler( + { + token: "tg-token", + accountId: "default", + cfg, + }, + { + nowMs: () => 1000, + sendTyping, + sendMessage, + editReplyMarkup, + }, + ); + return { handler, sendTyping, sendMessage, editReplyMarkup }; +} + +describe("TelegramExecApprovalHandler", () => { + it("sends approval prompts to the originating telegram topic when target=channel", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendTyping, sendMessage } = createHandler(cfg); + + await handler.handleRequested(baseRequest); + + expect(sendTyping).toHaveBeenCalledWith( + "-1003841603622", + expect.objectContaining({ + accountId: "default", + messageThreadId: 928, + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + "-1003841603622", + expect.stringContaining("/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once"), + expect.objectContaining({ + accountId: "default", + messageThreadId: 928, + buttons: [ + [ + { + text: "Allow Once", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-once", + }, + { + text: "Allow Always", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 allow-always", + }, + ], + [ + { + text: "Deny", + callback_data: "/approve 9f1c7d5d-b1fb-46ef-ac45-662723b65bb7 deny", + }, + ], + ], + }), + ); + }); + + it("falls back to approver DMs when channel routing is unavailable", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["111", "222"], + target: "channel", + }, + }, + }, + } as OpenClawConfig; + const { handler, sendMessage } = createHandler(cfg); + + await handler.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + turnSourceChannel: "slack", + turnSourceTo: "U1", + turnSourceAccountId: null, + turnSourceThreadId: null, + }, + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage.mock.calls.map((call) => call[0])).toEqual(["111", "222"]); + }); + + it("clears buttons from tracked approval messages when resolved", async () => { + const cfg = { + channels: { + telegram: { + execApprovals: { + enabled: true, + approvers: ["8460800771"], + target: "both", + }, + }, + }, + } as OpenClawConfig; + const { handler, editReplyMarkup } = createHandler(cfg); + + await handler.handleRequested(baseRequest); + await handler.handleResolved({ + id: baseRequest.id, + decision: "allow-once", + resolvedBy: "telegram:8460800771", + ts: 2000, + }); + + expect(editReplyMarkup).toHaveBeenCalled(); + expect(editReplyMarkup).toHaveBeenCalledWith( + "-1003841603622", + "m1", + [], + expect.objectContaining({ + accountId: "default", + }), + ); + }); +}); diff --git a/src/telegram/exec-approvals-handler.ts b/src/telegram/exec-approvals-handler.ts new file mode 100644 index 0000000000000..cc3d735e6a6b1 --- /dev/null +++ b/src/telegram/exec-approvals-handler.ts @@ -0,0 +1,418 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { GatewayClient } from "../gateway/client.js"; +import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; +import type { EventFrame } from "../gateway/protocol/index.js"; +import { + buildExecApprovalPendingReplyPayload, + type ExecApprovalPendingReplyParams, +} from "../infra/exec-approval-reply.js"; +import type { ExecApprovalRequest, ExecApprovalResolved } from "../infra/exec-approvals.js"; +import { resolveSessionDeliveryTarget } from "../infra/outbound/targets.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; +import { + getTelegramExecApprovalApprovers, + resolveTelegramExecApprovalConfig, + resolveTelegramExecApprovalTarget, +} from "./exec-approvals.js"; +import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js"; + +const log = createSubsystemLogger("telegram/exec-approvals"); + +type PendingMessage = { + chatId: string; + messageId: string; +}; + +type PendingApproval = { + timeoutId: NodeJS.Timeout; + messages: PendingMessage[]; +}; + +type TelegramApprovalTarget = { + to: string; + threadId?: number; +}; + +export type TelegramExecApprovalHandlerOpts = { + token: string; + accountId: string; + cfg: OpenClawConfig; + gatewayUrl?: string; + runtime?: RuntimeEnv; +}; + +export type TelegramExecApprovalHandlerDeps = { + nowMs?: () => number; + sendTyping?: typeof sendTypingTelegram; + sendMessage?: typeof sendMessageTelegram; + editReplyMarkup?: typeof editMessageReplyMarkupTelegram; +}; + +function matchesFilters(params: { + cfg: OpenClawConfig; + accountId: string; + request: ExecApprovalRequest; +}): boolean { + const config = resolveTelegramExecApprovalConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!config?.enabled) { + return false; + } + const approvers = getTelegramExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (approvers.length === 0) { + return false; + } + if (config.agentFilter?.length) { + const agentId = + params.request.request.agentId ?? + parseAgentSessionKey(params.request.request.sessionKey)?.agentId; + if (!agentId || !config.agentFilter.includes(agentId)) { + return false; + } + } + if (config.sessionFilter?.length) { + const sessionKey = params.request.request.sessionKey; + if (!sessionKey) { + return false; + } + const matches = config.sessionFilter.some((pattern) => { + if (sessionKey.includes(pattern)) { + return true; + } + const regex = compileSafeRegex(pattern); + return regex ? testRegexWithBoundedInput(regex, sessionKey) : false; + }); + if (!matches) { + return false; + } + } + return true; +} + +function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean { + const config = resolveTelegramExecApprovalConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!config?.enabled) { + return false; + } + return ( + getTelegramExecApprovalApprovers({ + cfg: params.cfg, + accountId: params.accountId, + }).length > 0 + ); +} + +function resolveRequestSessionTarget(params: { + cfg: OpenClawConfig; + request: ExecApprovalRequest; +}): { to: string; accountId?: string; threadId?: number; channel?: string } | null { + const sessionKey = params.request.request.sessionKey?.trim(); + if (!sessionKey) { + return null; + } + const parsed = parseAgentSessionKey(sessionKey); + const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main"; + const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); + const store = loadSessionStore(storePath); + const entry = store[sessionKey]; + if (!entry) { + return null; + } + const target = resolveSessionDeliveryTarget({ + entry, + requestedChannel: "last", + turnSourceChannel: params.request.request.turnSourceChannel ?? undefined, + turnSourceTo: params.request.request.turnSourceTo ?? undefined, + turnSourceAccountId: params.request.request.turnSourceAccountId ?? undefined, + turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, + }); + if (!target.to) { + return null; + } + return { + channel: target.channel ?? undefined, + to: target.to, + accountId: target.accountId ?? undefined, + threadId: + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined, + }; +} + +function resolveTelegramSourceTarget(params: { + cfg: OpenClawConfig; + accountId: string; + request: ExecApprovalRequest; +}): TelegramApprovalTarget | null { + const turnSourceChannel = params.request.request.turnSourceChannel?.trim().toLowerCase() || ""; + const turnSourceTo = params.request.request.turnSourceTo?.trim() || ""; + const turnSourceAccountId = params.request.request.turnSourceAccountId?.trim() || ""; + if (turnSourceChannel === "telegram" && turnSourceTo) { + if ( + turnSourceAccountId && + normalizeAccountId(turnSourceAccountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + const threadId = + typeof params.request.request.turnSourceThreadId === "number" + ? params.request.request.turnSourceThreadId + : typeof params.request.request.turnSourceThreadId === "string" + ? Number.parseInt(params.request.request.turnSourceThreadId, 10) + : undefined; + return { to: turnSourceTo, threadId: Number.isFinite(threadId) ? threadId : undefined }; + } + + const sessionTarget = resolveRequestSessionTarget(params); + if (!sessionTarget || sessionTarget.channel !== "telegram") { + return null; + } + if ( + sessionTarget.accountId && + normalizeAccountId(sessionTarget.accountId) !== normalizeAccountId(params.accountId) + ) { + return null; + } + return { + to: sessionTarget.to, + threadId: sessionTarget.threadId, + }; +} + +function dedupeTargets(targets: TelegramApprovalTarget[]): TelegramApprovalTarget[] { + const seen = new Set(); + const deduped: TelegramApprovalTarget[] = []; + for (const target of targets) { + const key = `${target.to}:${target.threadId ?? ""}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(target); + } + return deduped; +} + +export class TelegramExecApprovalHandler { + private gatewayClient: GatewayClient | null = null; + private pending = new Map(); + private started = false; + private readonly nowMs: () => number; + private readonly sendTyping: typeof sendTypingTelegram; + private readonly sendMessage: typeof sendMessageTelegram; + private readonly editReplyMarkup: typeof editMessageReplyMarkupTelegram; + + constructor( + private readonly opts: TelegramExecApprovalHandlerOpts, + deps: TelegramExecApprovalHandlerDeps = {}, + ) { + this.nowMs = deps.nowMs ?? Date.now; + this.sendTyping = deps.sendTyping ?? sendTypingTelegram; + this.sendMessage = deps.sendMessage ?? sendMessageTelegram; + this.editReplyMarkup = deps.editReplyMarkup ?? editMessageReplyMarkupTelegram; + } + + shouldHandle(request: ExecApprovalRequest): boolean { + return matchesFilters({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + }); + } + + async start(): Promise { + if (this.started) { + return; + } + this.started = true; + + if (!isHandlerConfigured({ cfg: this.opts.cfg, accountId: this.opts.accountId })) { + return; + } + + const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({ + config: this.opts.cfg, + url: this.opts.gatewayUrl, + }); + const gatewayUrlOverrideSource = + urlSource === "cli --url" + ? "cli" + : urlSource === "env OPENCLAW_GATEWAY_URL" + ? "env" + : undefined; + const auth = await resolveGatewayConnectionAuth({ + config: this.opts.cfg, + env: process.env, + urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined, + urlOverrideSource: gatewayUrlOverrideSource, + }); + + this.gatewayClient = new GatewayClient({ + url: gatewayUrl, + token: auth.token, + password: auth.password, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: `Telegram Exec Approvals (${this.opts.accountId})`, + mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes: ["operator.approvals"], + onEvent: (evt) => this.handleGatewayEvent(evt), + onConnectError: (err) => { + log.error(`telegram exec approvals: connect error: ${err.message}`); + }, + }); + this.gatewayClient.start(); + } + + async stop(): Promise { + if (!this.started) { + return; + } + this.started = false; + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + } + this.pending.clear(); + this.gatewayClient?.stop(); + this.gatewayClient = null; + } + + async handleRequested(request: ExecApprovalRequest): Promise { + if (!this.shouldHandle(request)) { + return; + } + + const targetMode = resolveTelegramExecApprovalTarget({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + }); + const targets: TelegramApprovalTarget[] = []; + const sourceTarget = resolveTelegramSourceTarget({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + request, + }); + let fallbackToDm = false; + if (targetMode === "channel" || targetMode === "both") { + if (sourceTarget) { + targets.push(sourceTarget); + } else { + fallbackToDm = true; + } + } + if (targetMode === "dm" || targetMode === "both" || fallbackToDm) { + for (const approver of getTelegramExecApprovalApprovers({ + cfg: this.opts.cfg, + accountId: this.opts.accountId, + })) { + targets.push({ to: approver }); + } + } + + const resolvedTargets = dedupeTargets(targets); + if (resolvedTargets.length === 0) { + return; + } + + const payloadParams: ExecApprovalPendingReplyParams = { + approvalId: request.id, + approvalSlug: request.id.slice(0, 8), + approvalCommandId: request.id, + command: request.request.command, + cwd: request.request.cwd ?? undefined, + host: request.request.host === "node" ? "node" : "gateway", + nodeId: request.request.nodeId ?? undefined, + expiresAtMs: request.expiresAtMs, + nowMs: this.nowMs(), + }; + const payload = buildExecApprovalPendingReplyPayload(payloadParams); + const buttons = buildTelegramExecApprovalButtons(request.id); + const sentMessages: PendingMessage[] = []; + + for (const target of resolvedTargets) { + try { + await this.sendTyping(target.to, { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + ...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}), + }).catch(() => {}); + + const result = await this.sendMessage(target.to, payload.text ?? "", { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + buttons, + ...(typeof target.threadId === "number" ? { messageThreadId: target.threadId } : {}), + }); + sentMessages.push({ + chatId: result.chatId, + messageId: result.messageId, + }); + } catch (err) { + log.error(`telegram exec approvals: failed to send request ${request.id}: ${String(err)}`); + } + } + + if (sentMessages.length === 0) { + return; + } + + const timeoutMs = Math.max(0, request.expiresAtMs - this.nowMs()); + const timeoutId = setTimeout(() => { + void this.handleResolved({ id: request.id, decision: "deny", ts: Date.now() }); + }, timeoutMs); + timeoutId.unref?.(); + + this.pending.set(request.id, { + timeoutId, + messages: sentMessages, + }); + } + + async handleResolved(resolved: ExecApprovalResolved): Promise { + const pending = this.pending.get(resolved.id); + if (!pending) { + return; + } + clearTimeout(pending.timeoutId); + this.pending.delete(resolved.id); + + await Promise.allSettled( + pending.messages.map(async (message) => { + await this.editReplyMarkup(message.chatId, message.messageId, [], { + cfg: this.opts.cfg, + token: this.opts.token, + accountId: this.opts.accountId, + }); + }), + ); + } + + private handleGatewayEvent(evt: EventFrame): void { + if (evt.event === "exec.approval.requested") { + void this.handleRequested(evt.payload as ExecApprovalRequest); + return; + } + if (evt.event === "exec.approval.resolved") { + void this.handleResolved(evt.payload as ExecApprovalResolved); + } + } +} diff --git a/src/telegram/exec-approvals.test.ts b/src/telegram/exec-approvals.test.ts new file mode 100644 index 0000000000000..d85e07f71872a --- /dev/null +++ b/src/telegram/exec-approvals.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + isTelegramExecApprovalApprover, + isTelegramExecApprovalClientEnabled, + resolveTelegramExecApprovalTarget, + shouldEnableTelegramExecApprovalButtons, + shouldInjectTelegramExecApprovalButtons, +} from "./exec-approvals.js"; + +function buildConfig( + execApprovals?: NonNullable["telegram"]>["execApprovals"], +): OpenClawConfig { + return { + channels: { + telegram: { + botToken: "tok", + execApprovals, + }, + }, + } as OpenClawConfig; +} + +describe("telegram exec approvals", () => { + it("requires enablement and at least one approver", () => { + expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: true }), + }), + ).toBe(false); + expect( + isTelegramExecApprovalClientEnabled({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + }), + ).toBe(true); + }); + + it("matches approvers by normalized sender id", () => { + const cfg = buildConfig({ enabled: true, approvers: [123, "456"] }); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true); + expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false); + }); + + it("defaults target to dm", () => { + expect( + resolveTelegramExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["1"] }) }), + ).toBe("dm"); + }); + + it("only injects approval buttons on eligible telegram targets", () => { + const dmCfg = buildConfig({ enabled: true, approvers: ["123"], target: "dm" }); + const channelCfg = buildConfig({ enabled: true, approvers: ["123"], target: "channel" }); + const bothCfg = buildConfig({ enabled: true, approvers: ["123"], target: "both" }); + + expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: dmCfg, to: "-100123" })).toBe(false); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "-100123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: channelCfg, to: "123" })).toBe(false); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "123" })).toBe(true); + expect(shouldInjectTelegramExecApprovalButtons({ cfg: bothCfg, to: "-100123" })).toBe(true); + }); + + it("does not require generic inlineButtons capability to enable exec approval buttons", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + capabilities: ["vision"], + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + + expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(true); + }); + + it("still respects explicit inlineButtons off for exec approval buttons", () => { + const cfg = { + channels: { + telegram: { + botToken: "tok", + capabilities: { inlineButtons: "off" }, + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + } as OpenClawConfig; + + expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false); + }); +}); diff --git a/src/telegram/exec-approvals.ts b/src/telegram/exec-approvals.ts new file mode 100644 index 0000000000000..1055e1d167668 --- /dev/null +++ b/src/telegram/exec-approvals.ts @@ -0,0 +1,106 @@ +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { TelegramExecApprovalConfig } from "../config/types.telegram.js"; +import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import { resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramTargetChatType } from "./targets.js"; + +function normalizeApproverId(value: string | number): string { + return String(value).trim(); +} + +export function resolveTelegramExecApprovalConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): TelegramExecApprovalConfig | undefined { + return resolveTelegramAccount(params).config.execApprovals; +} + +export function getTelegramExecApprovalApprovers(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + return (resolveTelegramExecApprovalConfig(params)?.approvers ?? []) + .map(normalizeApproverId) + .filter(Boolean); +} + +export function isTelegramExecApprovalClientEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const config = resolveTelegramExecApprovalConfig(params); + return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0); +} + +export function isTelegramExecApprovalApprover(params: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; +}): boolean { + const senderId = params.senderId?.trim(); + if (!senderId) { + return false; + } + const approvers = getTelegramExecApprovalApprovers(params); + return approvers.includes(senderId); +} + +export function resolveTelegramExecApprovalTarget(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): "dm" | "channel" | "both" { + return resolveTelegramExecApprovalConfig(params)?.target ?? "dm"; +} + +export function shouldInjectTelegramExecApprovalButtons(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): boolean { + if (!isTelegramExecApprovalClientEnabled(params)) { + return false; + } + const target = resolveTelegramExecApprovalTarget(params); + const chatType = resolveTelegramTargetChatType(params.to); + if (chatType === "direct") { + return target === "dm" || target === "both"; + } + if (chatType === "group") { + return target === "channel" || target === "both"; + } + return target === "both"; +} + +function resolveExecApprovalButtonsExplicitlyDisabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const capabilities = resolveTelegramAccount(params).config.capabilities; + if (!capabilities || Array.isArray(capabilities) || typeof capabilities !== "object") { + return false; + } + const inlineButtons = (capabilities as { inlineButtons?: unknown }).inlineButtons; + return typeof inlineButtons === "string" && inlineButtons.trim().toLowerCase() === "off"; +} + +export function shouldEnableTelegramExecApprovalButtons(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}): boolean { + if (!shouldInjectTelegramExecApprovalButtons(params)) { + return false; + } + return !resolveExecApprovalButtonsExplicitlyDisabled(params); +} + +export function shouldSuppressLocalTelegramExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; +}): boolean { + void params.cfg; + void params.accountId; + return getExecApprovalReplyMetadata(params.payload) !== null; +} diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index ed1e1a8744a80..7131876e6f1df 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -8,6 +8,7 @@ import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; +import { TelegramExecApprovalHandler } from "./exec-approvals-handler.js"; import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { TelegramPollingSession } from "./polling-session.js"; import { makeProxyFetch } from "./proxy.js"; @@ -73,6 +74,7 @@ const isGrammyHttpError = (err: unknown): boolean => { export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const log = opts.runtime?.error ?? console.error; let pollingSession: TelegramPollingSession | undefined; + let execApprovalsHandler: TelegramExecApprovalHandler | undefined; const unregisterHandler = registerUnhandledRejectionHandler((err) => { const isNetworkError = isRecoverableTelegramNetworkError(err, { context: "polling" }); @@ -111,6 +113,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const proxyFetch = opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined); + execApprovalsHandler = new TelegramExecApprovalHandler({ + token, + accountId: account.accountId, + cfg, + runtime: opts.runtime, + }); + await execApprovalsHandler.start(); + const persistedOffsetRaw = await readTelegramUpdateOffset({ accountId: account.accountId, botToken: token, @@ -178,6 +188,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { }); await pollingSession.runUntilAbort(); } finally { + await execApprovalsHandler?.stop().catch(() => {}); unregisterHandler(); } } diff --git a/src/telegram/send.test-harness.ts b/src/telegram/send.test-harness.ts index 57f47ac20d9ea..b8092034a9512 100644 --- a/src/telegram/send.test-harness.ts +++ b/src/telegram/send.test-harness.ts @@ -5,6 +5,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({ botApi: { deleteMessage: vi.fn(), editMessageText: vi.fn(), + sendChatAction: vi.fn(), sendMessage: vi.fn(), sendPoll: vi.fn(), sendPhoto: vi.fn(), diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 38097c49232a6..a34f27d196f0d 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -17,6 +17,7 @@ const { editMessageTelegram, reactMessageTelegram, sendMessageTelegram, + sendTypingTelegram, sendPollTelegram, sendStickerTelegram, } = await importTelegramSendModule(); @@ -171,6 +172,25 @@ describe("buildInlineKeyboard", () => { }); describe("sendMessageTelegram", () => { + it("sends typing to the resolved chat and topic", async () => { + loadConfig.mockReturnValue({ + channels: { + telegram: { + botToken: "tok", + }, + }, + }); + botApi.sendChatAction.mockResolvedValue(true); + + await sendTypingTelegram("telegram:group:-1001234567890:topic:271", { + accountId: "default", + }); + + expect(botApi.sendChatAction).toHaveBeenCalledWith("-1001234567890", "typing", { + message_thread_id: 271, + }); + }); + it("applies timeoutSeconds config precedence", async () => { const cases = [ { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 329329a07fffb..e1b352a0a61a5 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -22,7 +22,7 @@ import { normalizePollInput, type PollInput } from "../polls.js"; import { loadWebMedia } from "../web/media.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { buildTelegramThreadParams } from "./bot/helpers.js"; +import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -88,6 +88,16 @@ type TelegramReactionOpts = { retry?: RetryConfig; }; +type TelegramTypingOpts = { + cfg?: ReturnType; + token?: string; + accountId?: string; + verbose?: boolean; + api?: TelegramApiOverride; + retry?: RetryConfig; + messageThreadId?: number; +}; + function resolveTelegramMessageIdOrThrow( result: TelegramMessageLike | null | undefined, context: string, @@ -777,6 +787,39 @@ export async function sendMessageTelegram( return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) }; } +export async function sendTypingTelegram( + to: string, + opts: TelegramTypingOpts = {}, +): Promise<{ ok: true }> { + const { cfg, account, api } = resolveTelegramApiContext(opts); + const target = parseTelegramTarget(to); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: target.chatId, + persistTarget: to, + verbose: opts.verbose, + }); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), + }); + const threadParams = buildTypingThreadParams(target.messageThreadId ?? opts.messageThreadId); + await requestWithDiag( + () => + api.sendChatAction( + chatId, + "typing", + threadParams as Parameters[2], + ), + "typing", + ); + return { ok: true }; +} + export async function reactMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, @@ -873,6 +916,61 @@ type TelegramEditOpts = { cfg?: ReturnType; }; +type TelegramEditReplyMarkupOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: TelegramApiOverride; + retry?: RetryConfig; + /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ + buttons?: TelegramInlineButtons; + /** Optional config injection to avoid global loadConfig() (improves testability). */ + cfg?: ReturnType; +}; + +export async function editMessageReplyMarkupTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + buttons: TelegramInlineButtons, + opts: TelegramEditReplyMarkupOpts = {}, +): Promise<{ ok: true; messageId: string; chatId: string }> { + const { cfg, account, api } = resolveTelegramApiContext({ + ...opts, + cfg: opts.cfg, + }); + const rawTarget = String(chatIdInput); + const chatId = await resolveAndPersistChatId({ + cfg, + api, + lookupTarget: rawTarget, + persistTarget: rawTarget, + verbose: opts.verbose, + }); + const messageId = normalizeMessageId(messageIdInput); + const requestWithDiag = createTelegramRequestWithDiag({ + cfg, + account, + retry: opts.retry, + verbose: opts.verbose, + }); + const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; + try { + await requestWithDiag( + () => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }), + "editMessageReplyMarkup", + { + shouldLog: (err) => !isTelegramMessageNotModifiedError(err), + }, + ); + } catch (err) { + if (!isTelegramMessageNotModifiedError(err)) { + throw err; + } + } + logVerbose(`[telegram] Edited reply markup for message ${messageId} in chat ${chatId}`); + return { ok: true, messageId: String(messageId), chatId }; +} + export async function editMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, From 731f1aa9062a31f11f6bf79cafb17c2dc3794a4a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:43:07 +0530 Subject: [PATCH 310/311] test: avoid detect-secrets churn in observation fixtures --- .secrets.baseline | 8 ++++---- src/agents/pi-embedded-error-observation.test.ts | 15 +++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index b1f909e6ca4e7..5a0c639b9e309 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -205,7 +205,7 @@ "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1763 + "line_number": 1859 } ], "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ @@ -266,7 +266,7 @@ "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", "is_verified": false, - "line_number": 1763 + "line_number": 1859 } ], "docs/.i18n/zh-CN.tm.jsonl": [ @@ -11659,7 +11659,7 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 292 + "line_number": 291 } ], "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ @@ -13013,5 +13013,5 @@ } ] }, - "generated_at": "2026-03-09T08:37:13Z" + "generated_at": "2026-03-10T03:11:06Z" } diff --git a/src/agents/pi-embedded-error-observation.test.ts b/src/agents/pi-embedded-error-observation.test.ts index 94979ebfb8cd4..4e1d6162d5c83 100644 --- a/src/agents/pi-embedded-error-observation.test.ts +++ b/src/agents/pi-embedded-error-observation.test.ts @@ -6,6 +6,9 @@ import { sanitizeForConsole, } from "./pi-embedded-error-observation.js"; +const OBSERVATION_BEARER_TOKEN = "sk-redact-test-token"; +const OBSERVATION_COOKIE_VALUE = "session-cookie-token"; + afterEach(() => { vi.restoreAllMocks(); }); @@ -29,27 +32,27 @@ describe("buildApiErrorObservationFields", () => { it("forces token redaction for observation previews", () => { const observed = buildApiErrorObservationFields( - "Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456", + `Authorization: Bearer ${OBSERVATION_BEARER_TOKEN}`, ); - expect(observed.rawErrorPreview).not.toContain("sk-abcdefghijklmnopqrstuvwxyz123456"); - expect(observed.rawErrorPreview).toContain("sk-abc"); + expect(observed.rawErrorPreview).not.toContain(OBSERVATION_BEARER_TOKEN); + expect(observed.rawErrorPreview).toContain(OBSERVATION_BEARER_TOKEN.slice(0, 6)); expect(observed.rawErrorHash).toMatch(/^sha256:/); }); it("redacts observation-only header and cookie formats", () => { const observed = buildApiErrorObservationFields( - "x-api-key: sk-abcdefghijklmnopqrstuvwxyz123456 Cookie: session=abcdefghijklmnopqrstuvwxyz123456", + `x-api-key: ${OBSERVATION_BEARER_TOKEN} Cookie: session=${OBSERVATION_COOKIE_VALUE}`, ); - expect(observed.rawErrorPreview).not.toContain("abcdefghijklmnopqrstuvwxyz123456"); + expect(observed.rawErrorPreview).not.toContain(OBSERVATION_COOKIE_VALUE); expect(observed.rawErrorPreview).toContain("x-api-key: ***"); expect(observed.rawErrorPreview).toContain("Cookie: session="); }); it("does not let cookie redaction consume unrelated fields on the same line", () => { const observed = buildApiErrorObservationFields( - "Cookie: session=abcdefghijklmnopqrstuvwxyz123456 status=503 request_id=req_cookie", + `Cookie: session=${OBSERVATION_COOKIE_VALUE} status=503 request_id=req_cookie`, ); expect(observed.rawErrorPreview).toContain("Cookie: session="); From e74666cd0af0ddfb14970c81dcf2d7b470336be6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 08:47:56 +0530 Subject: [PATCH 311/311] build: raise extension openclaw peer floor --- extensions/googlechat/package.json | 2 +- extensions/memory-core/package.json | 2 +- pnpm-lock.yaml | 551 ++-------------------------- 3 files changed, 23 insertions(+), 532 deletions(-) diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 2abe2abbe38ba..2c1db3bcd2719 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -8,7 +8,7 @@ "google-auth-library": "^10.6.1" }, "peerDependencies": { - "openclaw": ">=2026.3.2" + "openclaw": ">=2026.3.7" }, "peerDependenciesMeta": { "openclaw": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index ca69729004781..664d0a469f402 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw core memory search plugin", "type": "module", "peerDependencies": { - "openclaw": ">=2026.3.2" + "openclaw": ">=2026.3.7" }, "peerDependenciesMeta": { "openclaw": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae9ea71e0c63..b2043db207dbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,8 +338,8 @@ importers: specifier: ^10.6.1 version: 10.6.1 openclaw: - specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/imessage: {} @@ -399,8 +399,8 @@ importers: extensions/memory-core: dependencies: openclaw: - specifier: '>=2026.3.2' - version: 2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) + specifier: '>=2026.3.7' + version: 2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)) extensions/memory-lancedb: dependencies: @@ -618,18 +618,10 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-bedrock-runtime@3.1000.0': - resolution: {integrity: sha512-GA96wgTFB4Z5vhysm+hErbgiEWZ9JqAl09BxARajL7Oanpf0KvdIjxuLp2rD/XqEIks9yG/5Rh9XIAoCUUTZXw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock-runtime@3.1004.0': resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1000.0': - resolution: {integrity: sha512-wGU8uJXrPW/hZuHdPNVe1kAFIBiKcslBcoDBN0eYBzS13um8p5jJiQJ9WsD1nSpKCmyx7qZXc6xjcbIQPyOrrA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/client-bedrock@3.1004.0': resolution: {integrity: sha512-JbfZSV85IL+43S7rPBmeMbvoOYXs1wmrfbEpHkDBjkvbukRQWtoetiPAXNSKDfFq1qVsoq8sWPdoerDQwlUO8w==} engines: {node: '>=20.0.0'} @@ -718,18 +710,10 @@ packages: resolution: {integrity: sha512-g2Z9s6Y4iNh0wICaEqutgYgt/Pmhv5Ev9G3eKGFe2w9VuZDhc76vYdop6I5OocmpHV79d4TuLG+JWg5rQIVDVA==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.9': - resolution: {integrity: sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-bucket-endpoint@3.972.6': resolution: {integrity: sha512-3H2bhvb7Cb/S6WFsBy/Dy9q2aegC9JmGH1inO8Lb2sWirSqpLJlZmvQHPE29h2tIxzv6el/14X/tLCQ8BQU6ZQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.6': - resolution: {integrity: sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.7': resolution: {integrity: sha512-VWndapHYCfwLgPpCb/xwlMKG4imhFzKJzZcKOEioGn7OHY+6gdr0K7oqy1HZgbLa3ACznZ9fku+DzmAi8fUC0g==} engines: {node: '>=20.0.0'} @@ -786,10 +770,6 @@ packages: resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.10': - resolution: {integrity: sha512-uNqRpbL6djE+XXO4cQ+P8ra37cxNNBP+2IfkVOXu1xFdGMfW+uOTxBQuDPpP43i40PBRBXK5un79l/oYpbzYkA==} - engines: {node: '>= 14.0.0'} - '@aws-sdk/middleware-websocket@3.972.12': resolution: {integrity: sha512-iyPP6FVDKe/5wy5ojC0akpDFG1vX3FeCUU47JuwN8xfvT66xlEI8qUJZPtN55TJVFzzWZJpWL78eqUE31md08Q==} engines: {node: '>= 14.0.0'} @@ -818,10 +798,6 @@ packages: resolution: {integrity: sha512-gQYI/Buwp0CAGQxY7mR5VzkP56rkWq2Y1ROkFuXh5XY94DsSjJw62B3I0N0lysQmtwiL2ht2KHI9NylM/RP4FA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1000.0': - resolution: {integrity: sha512-eOI+8WPtWpLdlYBGs8OCK3k5uIMUHVsNG3AFO4kaRaZcKReJ/2OO6+2O2Dd/3vTzM56kRjSKe7mBOCwa4PdYqg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1004.0': resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} engines: {node: '>=20.0.0'} @@ -980,15 +956,9 @@ packages: '@cacheable/utils@2.3.4': resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==} - '@clack/core@1.0.1': - resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} - '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} - '@clack/prompts@1.0.1': - resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} - '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} @@ -1222,15 +1192,6 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} - '@google/genai@1.43.0': - resolution: {integrity: sha512-hklCsJNdMlDM1IwcCVcGQFBg2izY0+t5BIGbRsxi2UnKi6AGKL7pqJqmBDNRbw0bYCs4y3NA7TB+fkKfP/Nrdw==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.25.2 - peerDependenciesMeta: - '@modelcontextprotocol/sdk': - optional: true - '@google/genai@1.44.0': resolution: {integrity: sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==} engines: {node: '>=20.0.0'} @@ -1644,38 +1605,20 @@ packages: resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} hasBin: true - '@mariozechner/pi-agent-core@0.55.3': - resolution: {integrity: sha512-rqbfpQ9BrP6BDiW+Ps3A8Z/p9+Md/pAfc/ECq8JP6cwnZL/jQgU355KWZKtF8zM9az1p0Q9hIWi9cQygVo6Auw==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-agent-core@0.57.1': resolution: {integrity: sha512-WXsBbkNWOObFGHkhixaT8GXJpHDd3+fn8QntYF+4R8Sa9WB90ENXWidO6b7vcKX+JX0jjO5dIsQxmzosARJKlg==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.55.3': - resolution: {integrity: sha512-f9jWoDzJR9Wy/H8JPMbjoM4WvVUeFZ65QdYA9UHIfoOopDfwWE8F8JHQOj5mmmILMacXuzsqA3J7MYqNWZRvvQ==} - engines: {node: '>=20.0.0'} - hasBin: true - '@mariozechner/pi-ai@0.57.1': resolution: {integrity: sha512-Bd/J4a3YpdzJVyHLih0vDSdB0QPL4ti0XsAwtHOK/8eVhB0fHM1CpcgIrcBFJ23TMcKXMi0qamz18ERfp8tmgg==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.55.3': - resolution: {integrity: sha512-5SFbB7/BIp/Crjre7UNjUeNfpoU1KSW/i6LXa+ikJTBqI5LukWq2avE5l0v0M8Pg/dt1go2XCLrNFlQJiQDSPQ==} - engines: {node: '>=20.0.0'} - hasBin: true - '@mariozechner/pi-coding-agent@0.57.1': resolution: {integrity: sha512-u5MQEduj68rwVIsRsqrWkJYiJCyPph/a6bMoJAQKo1sb+Pc17Y/ojwa+wGssnUMjEB38AQKofWTVe8NFEpSWNw==} engines: {node: '>=20.6.0'} hasBin: true - '@mariozechner/pi-tui@0.55.3': - resolution: {integrity: sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-tui@0.57.1': resolution: {integrity: sha512-cjoRghLbeAHV0tTJeHgZXaryUi5zzBZofeZ7uJun1gztnckLLRjoVeaPTujNlc5BIfyKvFqhh1QWCZng/MXlpg==} engines: {node: '>=20.0.0'} @@ -1692,9 +1635,6 @@ packages: resolution: {integrity: sha512-570oJr93l1RcCNNaMVpOm+PgQkRgno/F65nH1aCWLIKLnw0o7iPoj+8Z5b7mnLMidg9lldVSCcf0dBxqTGE1/w==} engines: {node: '>=20.0.0'} - '@mistralai/mistralai@1.10.0': - resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==} - '@mistralai/mistralai@1.14.1': resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} @@ -3198,93 +3138,6 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} 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==} @@ -4210,9 +4063,6 @@ packages: discord-api-types@0.38.37: resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} - discord-api-types@0.38.40: - resolution: {integrity: sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==} - discord-api-types@0.38.41: resolution: {integrity: sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==} @@ -4614,10 +4464,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammy@1.41.0: - resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==} - engines: {node: ^12.20.0 || >=14.13.1} - grammy@1.41.1: resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==} engines: {node: ^12.20.0 || >=14.13.1} @@ -5466,18 +5312,6 @@ packages: oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} - openai@6.10.0: - resolution: {integrity: sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -5502,8 +5336,8 @@ packages: zod: optional: true - openclaw@2026.3.2: - resolution: {integrity: sha512-Gkqx24m7PF1DUXPI968DuC9n52lTZ5hI3X5PIi0HosC7J7d6RLkgVppj1mxvgiQAWMp41E41elvoi/h4KBjFcQ==} + openclaw@2026.3.8: + resolution: {integrity: sha512-e5Rk2Aj55sD/5LyX94mdYCQj7zpHXo0xIZsl+k140+nRopePfPAxC7nsu0V/NyypPRtaotP1riFfzK7IhaYkuQ==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -6746,9 +6580,6 @@ packages: zod@3.25.75: resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -6818,58 +6649,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-bedrock-runtime@3.1000.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 - '@aws-sdk/eventstream-handler-node': 3.972.9 - '@aws-sdk/middleware-eventstream': 3.972.6 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/middleware-websocket': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/token-providers': 3.1000.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@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-runtime@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6922,51 +6701,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-bedrock@3.1000.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/token-providers': 3.1000.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@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-bedrock@3.1004.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -7324,13 +7058,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/eventstream-handler-node@3.972.9': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/eventstream-codec': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.6': dependencies: '@aws-sdk/types': 3.973.4 @@ -7341,13 +7068,6 @@ snapshots: '@smithy/util-config-provider': 4.2.1 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.7': dependencies: '@aws-sdk/types': 3.973.5 @@ -7471,21 +7191,6 @@ snapshots: '@smithy/util-retry': 4.2.11 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.10': - dependencies: - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-format-url': 3.972.6 - '@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/middleware-websocket@3.972.12': dependencies: '@aws-sdk/types': 3.973.5 @@ -7623,18 +7328,6 @@ snapshots: '@smithy/types': 4.13.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1000.0': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@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/token-providers@3.1004.0': dependencies: '@aws-sdk/core': 3.973.18 @@ -7858,21 +7551,10 @@ snapshots: hashery: 1.5.0 keyv: 5.6.0 - '@clack/core@1.0.1': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/core@1.1.0': dependencies: sisteransi: 1.0.5 - '@clack/prompts@1.0.1': - dependencies: - '@clack/core': 1.0.1 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.1.0': dependencies: '@clack/core': 1.1.0 @@ -8100,17 +7782,6 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true - '@google/genai@1.43.0': - dependencies: - google-auth-library: 10.6.1 - p-retry: 4.6.2 - protobufjs: 7.5.4 - ws: 8.19.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.44.0': dependencies: google-auth-library: 10.6.1 @@ -8122,21 +7793,11 @@ snapshots: - supports-color - utf-8-validate - '@grammyjs/runner@2.0.3(grammy@1.41.0)': - dependencies: - abort-controller: 3.0.0 - grammy: 1.41.0 - '@grammyjs/runner@2.0.3(grammy@1.41.1)': dependencies: abort-controller: 3.0.0 grammy: 1.41.1 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.0)': - dependencies: - bottleneck: 2.19.5 - grammy: 1.41.0 - '@grammyjs/transformer-throttler@1.2.1(grammy@1.41.1)': dependencies: bottleneck: 2.19.5 @@ -8501,18 +8162,6 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) @@ -8525,30 +8174,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.55.3(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.1000.0 - '@google/genai': 1.43.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-ai@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -8573,37 +8198,6 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.55.3(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.3 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.3 - extract-zip: 2.0.1 - 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.4 - proper-lockfile: 4.1.2 - strip-ansi: 7.2.0 - 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-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 @@ -8636,15 +8230,6 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.55.3': - 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 - '@mariozechner/pi-tui@0.57.1': dependencies: '@types/mime-types': 2.1.4 @@ -8684,11 +8269,6 @@ snapshots: - debug - supports-color - '@mistralai/mistralai@1.10.0': - dependencies: - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - '@mistralai/mistralai@1.14.1': dependencies: ws: 8.19.0 @@ -10291,67 +9871,6 @@ 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': @@ -11364,8 +10883,6 @@ snapshots: discord-api-types@0.38.37: {} - discord-api-types@0.38.40: {} - discord-api-types@0.38.41: {} doctypes@1.1.0: {} @@ -11876,16 +11393,6 @@ snapshots: graceful-fs@4.2.11: {} - grammy@1.41.0: - dependencies: - '@grammyjs/types': 3.25.0 - abort-controller: 3.0.0 - debug: 4.4.3 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - supports-color - grammy@1.41.1: dependencies: '@grammyjs/types': 3.25.0 @@ -12287,7 +11794,8 @@ snapshots: klona@2.0.6: {} - koffi@2.15.1: {} + koffi@2.15.1: + optional: true leac@0.6.0: {} @@ -12806,11 +12314,6 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - openai@6.10.0(ws@8.19.0)(zod@4.3.6): - optionalDependencies: - ws: 8.19.0 - zod: 4.3.6 - openai@6.26.0(ws@8.19.0)(zod@4.3.6): optionalDependencies: ws: 8.19.0 @@ -12821,29 +12324,28 @@ snapshots: ws: 8.19.0 zod: 4.3.6 - openclaw@2026.3.2(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): + openclaw@2026.3.8(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(hono@4.12.5)(node-llama-cpp@3.16.2(typescript@5.9.3)): dependencies: - '@agentclientprotocol/sdk': 0.14.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.1000.0 + '@agentclientprotocol/sdk': 0.15.0(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.1004.0 '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.5)(opusscript@0.1.1) - '@clack/prompts': 1.0.1 + '@clack/prompts': 1.1.0 '@discordjs/voice': 0.19.0(@discordjs/opus@0.10.0)(opusscript@0.1.1) - '@grammyjs/runner': 2.0.3(grammy@1.41.0) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.0) + '@grammyjs/runner': 2.0.3(grammy@1.41.1) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1) '@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.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.55.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.55.3 + '@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.57.1(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.57.1 '@mozilla/readability': 0.6.0 '@napi-rs/canvas': 0.1.95 '@sinclair/typebox': 0.34.48 '@slack/bolt': 4.6.0(@types/express@5.0.6) '@slack/web-api': 7.14.1 - '@snazzah/davey': 0.1.9 '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) ajv: 8.18.0 chalk: 5.6.2 @@ -12851,13 +12353,11 @@ snapshots: cli-highlight: 2.1.11 commander: 14.0.3 croner: 10.0.1 - discord-api-types: 0.38.40 + discord-api-types: 0.38.41 dotenv: 17.3.1 express: 5.2.1 file-type: 21.3.0 - gaxios: 7.1.3 - google-auth-library: 10.6.1 - grammy: 1.41.0 + grammy: 1.41.1 https-proxy-agent: 7.0.6 ipaddr.js: 2.3.0 jiti: 2.6.1 @@ -12866,7 +12366,6 @@ snapshots: linkedom: 0.18.12 long: 5.3.2 markdown-it: 14.1.1 - node-domexception: '@nolyfill/domexception@1.0.28' node-edge-tts: 1.2.10 node-llama-cpp: 3.16.2(typescript@5.9.3) opusscript: 0.1.1 @@ -12876,16 +12375,14 @@ snapshots: qrcode-terminal: 0.12.0 sharp: 0.34.5 sqlite-vec: 0.1.7-alpha.2 - strip-ansi: 7.2.0 tar: 7.5.10 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: + - '@discordjs/opus' - '@modelcontextprotocol/sdk' - '@types/express' - audio-decode @@ -14298,18 +13795,12 @@ snapshots: - bufferutil - utf-8-validate - zod-to-json-schema@3.25.1(zod@3.25.76): - dependencies: - zod: 3.25.76 - zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 zod@3.25.75: {} - zod@3.25.76: {} - zod@4.3.6: {} zwitch@2.0.4: {}

View full changelog