From b72c87712d7febccce17d7fbb9970aee4f4f1200 Mon Sep 17 00:00:00 2001 From: atian8179 Date: Fri, 13 Mar 2026 19:29:36 +0800 Subject: [PATCH 001/663] fix(config): add missing params field to agents.list[] validation schema (#41171) Merged via squash. Prepared head SHA: 9522761cf1d5f5318b6b44abfb1292384acd9c37 Co-authored-by: atian8179 <255488364+atian8179@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/config/config-misc.test.ts | 27 ++++++++++++++++++++++++++ src/config/zod-schema.agent-runtime.ts | 1 + 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8820bea39f..434b2b8fd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei. - Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. - Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. +- Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179. ## 2026.3.12 diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 2a1972d9040..bd9a05fea10 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -361,6 +361,33 @@ describe("config strict validation", () => { expect(res.ok).toBe(false); }); + it("accepts documented agents.list[].params overrides", () => { + const res = validateConfigObject({ + agents: { + list: [ + { + id: "main", + model: "anthropic/claude-opus-4-6", + params: { + cacheRetention: "none", + temperature: 0.4, + maxTokens: 8192, + }, + }, + ], + }, + }); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.agents?.list?.[0]?.params).toEqual({ + cacheRetention: "none", + temperature: 0.4, + maxTokens: 8192, + }); + } + }); + it("flags legacy config entries without auto-migrating", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 28c7cfaabed..680b79cdc16 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -757,6 +757,7 @@ export const AgentEntrySchema = z .strict() .optional(), sandbox: AgentSandboxSchema, + params: z.record(z.string(), z.unknown()).optional(), tools: AgentToolsSchema, runtime: AgentRuntimeSchema, }) From b934cb49c73974f75bb88eaf63c0e0c551d0c773 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 16:32:11 +0530 Subject: [PATCH 002/663] fix(android): use Google Code Scanner for onboarding QR --- apps/android/app/build.gradle.kts | 2 +- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 65 +++++++++++-------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 11e971a1e37..b187e131048 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -194,7 +194,7 @@ dependencies { implementation("androidx.camera:camera-lifecycle:1.5.2") implementation("androidx.camera:camera-video:1.5.2") implementation("androidx.camera:camera-view:1.5.2") - implementation("com.journeyapps:zxing-android-embedded:4.3.0") + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. implementation("dnsjava:dnsjava:3.6.4") 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 bf4f723c242..71d5b84f5d5 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 @@ -96,8 +96,9 @@ import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.R import ai.openclaw.app.node.DeviceNotificationListenerService -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanOptions +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning private enum class OnboardingStep(val index: Int, val label: String) { Welcome(1, "Welcome"), @@ -241,6 +242,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { var attemptedConnect by rememberSaveable { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current + val qrScannerOptions = + remember { + GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + } + val qrScanner = remember(context, qrScannerOptions) { GmsBarcodeScanning.getClient(context, qrScannerOptions) } val smsAvailable = remember(context) { @@ -460,23 +468,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - val qrScanLauncher = - rememberLauncherForActivityResult(ScanContract()) { result -> - val contents = result.contents?.trim().orEmpty() - if (contents.isEmpty()) { - return@rememberLauncherForActivityResult - } - val scannedSetupCode = resolveScannedSetupCode(contents) - if (scannedSetupCode == null) { - gatewayError = "QR code did not contain a valid setup code." - return@rememberLauncherForActivityResult - } - setupCode = scannedSetupCode - gatewayInputMode = GatewayInputMode.SetupCode - gatewayError = null - attemptedConnect = false - } - if (pendingTrust != null) { val prompt = pendingTrust!! AlertDialog( @@ -552,14 +543,28 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { gatewayError = gatewayError, onScanQrClick = { gatewayError = null - qrScanLauncher.launch( - ScanOptions().apply { - setDesiredBarcodeFormats(ScanOptions.QR_CODE) - setPrompt("Scan OpenClaw onboarding QR") - setBeepEnabled(false) - setOrientationLocked(false) - }, - ) + qrScanner.startScan() + .addOnSuccessListener { barcode -> + val contents = barcode.rawValue?.trim().orEmpty() + if (contents.isEmpty()) { + return@addOnSuccessListener + } + val scannedSetupCode = resolveScannedSetupCode(contents) + if (scannedSetupCode == null) { + gatewayError = "QR code did not contain a valid setup code." + return@addOnSuccessListener + } + setupCode = scannedSetupCode + gatewayInputMode = GatewayInputMode.SetupCode + gatewayError = null + attemptedConnect = false + } + .addOnCanceledListener { + // User dismissed the scanner; preserve current form state. + } + .addOnFailureListener { error -> + gatewayError = resolveQrScannerError(error) + } }, onAdvancedOpenChange = { gatewayAdvancedOpen = it }, onInputModeChange = { @@ -1785,6 +1790,12 @@ private fun isPermissionGranted(context: Context, permission: String): Boolean { return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } +private fun resolveQrScannerError(error: Exception): String { + val detail = error.message?.trim().orEmpty() + val prefix = "Google Code Scanner could not start. Update Google Play services or use the setup code manually." + return if (detail.isEmpty()) prefix else "$prefix ($detail)" +} + private fun isNotificationListenerEnabled(context: Context): Boolean { return DeviceNotificationListenerService.isAccessEnabled(context) } From 45721d5dec81f707b44c53cae5d866aaf932c12a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 13 Mar 2026 17:12:07 +0530 Subject: [PATCH 003/663] fix: polish Android QR scanner onboarding (#45021) --- CHANGELOG.md | 1 + .../src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 434b2b8fd31..d3eb7679f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference. - Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. - Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179. +- Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus. ## 2026.3.12 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 71d5b84f5d5..db550ded615 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 @@ -562,8 +562,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { .addOnCanceledListener { // User dismissed the scanner; preserve current form state. } - .addOnFailureListener { error -> - gatewayError = resolveQrScannerError(error) + .addOnFailureListener { + gatewayError = qrScannerErrorMessage() } }, onAdvancedOpenChange = { gatewayAdvancedOpen = it }, @@ -1790,10 +1790,8 @@ private fun isPermissionGranted(context: Context, permission: String): Boolean { return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED } -private fun resolveQrScannerError(error: Exception): String { - val detail = error.message?.trim().orEmpty() - val prefix = "Google Code Scanner could not start. Update Google Play services or use the setup code manually." - return if (detail.isEmpty()) prefix else "$prefix ($detail)" +private fun qrScannerErrorMessage(): String { + return "Google Code Scanner could not start. Update Google Play services or use the setup code manually." } private fun isNotificationListenerEnabled(context: Context): Boolean { From 4e68684bd28886b8b2bcd4aed9f57bb6ae47ff9a Mon Sep 17 00:00:00 2001 From: stim64045-spec Date: Fri, 13 Mar 2026 19:56:26 +0800 Subject: [PATCH 004/663] fix: restore web fetch firecrawl config in runtime zod schema (#42583) Merged via squash. Prepared head SHA: e37f965b8ef5370f07e1492499c6a87aa26a178a Co-authored-by: stim64045-spec <259352523+stim64045-spec@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/config/schema.test.ts | 46 ++++++++++++++++++++++++++ src/config/zod-schema.agent-runtime.ts | 12 +++++++ 3 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3eb7679f5b..6c189efda09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97. - Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179. - Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus. +- Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec. ## 2026.3.12 diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 54aaa79c846..3d6ecced2ca 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { buildConfigSchema, lookupConfigSchema } from "./schema.js"; import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; +import { ToolsSchema } from "./zod-schema.agent-runtime.js"; describe("config schema", () => { type SchemaInput = NonNullable[0]>; @@ -200,6 +201,51 @@ describe("config schema", () => { expect(tags).toContain("performance"); }); + it("accepts web fetch readability and firecrawl config in the runtime zod schema", () => { + const parsed = ToolsSchema.parse({ + web: { + fetch: { + readability: true, + firecrawl: { + enabled: true, + apiKey: "firecrawl-test-key", + baseUrl: "https://api.firecrawl.dev", + onlyMainContent: true, + maxAgeMs: 60_000, + timeoutSeconds: 15, + }, + }, + }, + }); + + expect(parsed?.web?.fetch?.readability).toBe(true); + expect(parsed?.web?.fetch).toMatchObject({ + firecrawl: { + enabled: true, + apiKey: "firecrawl-test-key", + baseUrl: "https://api.firecrawl.dev", + onlyMainContent: true, + maxAgeMs: 60_000, + timeoutSeconds: 15, + }, + }); + }); + + it("rejects unknown keys inside web fetch firecrawl config", () => { + expect(() => + ToolsSchema.parse({ + web: { + fetch: { + firecrawl: { + enabled: true, + nope: true, + }, + }, + }, + }), + ).toThrow(); + }); + it("keeps tags in the allowed taxonomy", () => { const withTags = applyDerivedTags({ "gateway.auth.token": {}, diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 680b79cdc16..7a87440a768 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -327,6 +327,18 @@ export const ToolsWebFetchSchema = z cacheTtlMinutes: z.number().nonnegative().optional(), maxRedirects: z.number().int().nonnegative().optional(), userAgent: z.string().optional(), + readability: z.boolean().optional(), + firecrawl: z + .object({ + enabled: z.boolean().optional(), + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + onlyMainContent: z.boolean().optional(), + maxAgeMs: z.number().int().nonnegative().optional(), + timeoutSeconds: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .optional(); From 61429230b202dbf2773a50c31f6cc4318f9420b0 Mon Sep 17 00:00:00 2001 From: Alex Zaytsev <32521398+unisone@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:14:30 -0400 Subject: [PATCH 005/663] fix(signal): add groups config to Signal channel schema (#27199) Merged via squash. Prepared head SHA: 4ba4a39ddf10eaa3d6ad3f2975c088547f5373e5 Co-authored-by: unisone <32521398+unisone@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + docs/channels/signal.md | 4 ++ src/config/types.signal.ts | 9 +++ src/config/zod-schema.providers-core.ts | 11 ++++ src/config/zod-schema.signal-groups.test.ts | 65 +++++++++++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 src/config/zod-schema.signal-groups.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c189efda09..afdf2c745e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179. - Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus. - Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec. +- Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. ## 2026.3.12 diff --git a/docs/channels/signal.md b/docs/channels/signal.md index b216af120ce..cfc050b6e75 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -195,6 +195,8 @@ Groups: - `channels.signal.groupPolicy = open | allowlist | disabled`. - `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- `channels.signal.groups["" | "*"]` can override group behavior with `requireMention`, `tools`, and `toolsBySender`. +- Use `channels.signal.accounts..groups` for per-account overrides in multi-account setups. - Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## How it works (behavior) @@ -312,6 +314,8 @@ Provider options: - `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids. - `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.signal.groupAllowFrom`: group sender allowlist. +- `channels.signal.groups`: per-group overrides keyed by Signal group id (or `"*"`). Supported fields: `requireMention`, `tools`, `toolsBySender`. +- `channels.signal.accounts..groups`: per-account version of `channels.signal.groups` for multi-account setups. - `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms[""].historyLimit`. - `channels.signal.textChunkLimit`: outbound chunk size (chars). diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 1f3d5180b92..bd33a64cf51 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,8 +1,15 @@ import type { CommonChannelMessagingConfig } from "./types.channel-messaging-common.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive"; +export type SignalGroupConfig = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; +}; + export type SignalAccountConfig = CommonChannelMessagingConfig & { /** Optional explicit E.164 account for signal-cli. */ account?: string; @@ -24,6 +31,8 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & { ignoreAttachments?: boolean; ignoreStories?: boolean; sendReadReceipts?: boolean; + /** Per-group overrides keyed by Signal group id (or "*"). */ + groups?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 2b2fccee310..47f76614dd8 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -971,6 +971,16 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({ validateSlackSigningSecretRequirements(value, ctx); }); +const SignalGroupEntrySchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, + }) + .strict(); + +const SignalGroupsSchema = z.record(z.string(), SignalGroupEntrySchema.optional()).optional(); + export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), @@ -995,6 +1005,7 @@ export const SignalAccountSchemaBase = z defaultTo: z.string().optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groups: SignalGroupsSchema, historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), diff --git a/src/config/zod-schema.signal-groups.test.ts b/src/config/zod-schema.signal-groups.test.ts new file mode 100644 index 00000000000..2dcd1ac0676 --- /dev/null +++ b/src/config/zod-schema.signal-groups.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("signal groups schema", () => { + it("accepts top-level Signal groups overrides", () => { + const res = validateConfigObject({ + channels: { + signal: { + groups: { + "*": { + requireMention: false, + }, + "+1234567890": { + requireMention: true, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts per-account Signal groups overrides", () => { + const res = validateConfigObject({ + channels: { + signal: { + accounts: { + primary: { + groups: { + "*": { + requireMention: false, + }, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("rejects unknown keys in Signal groups entries", () => { + const res = validateConfigObject({ + channels: { + signal: { + groups: { + "*": { + requireMention: false, + nope: true, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((issue) => issue.path.startsWith("channels.signal.groups"))).toBe( + true, + ); + } + }); +}); From 496176d738341219e90455505f91aa5bc5035091 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 13 Mar 2026 14:24:15 +0200 Subject: [PATCH 006/663] feat(ios): add onboarding welcome pager (#45054) * feat(ios): add onboarding welcome pager * feat(ios): add onboarding welcome pager (#45054) (thanks @ngutman) --- CHANGELOG.md | 1 + .../ShareExtension/ShareViewController.swift | 2 + .../Onboarding/OnboardingStateStore.swift | 14 ++ .../Onboarding/OnboardingWizardView.swift | 139 ++++++++++++++++-- apps/ios/Sources/Settings/SettingsTab.swift | 1 + .../ios/Tests/OnboardingStateStoreTests.swift | 29 ++++ 6 files changed, 172 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afdf2c745e2..3792a78a34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. - Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. +- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman. ### Fixes diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift index 1181641e330..00f1b06f9dc 100644 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -189,6 +189,7 @@ final class ShareViewController: UIViewController { try await gateway.connect( url: url, token: config.token, + bootstrapToken: nil, password: config.password, connectOptions: makeOptions("openclaw-ios"), sessionBox: nil, @@ -208,6 +209,7 @@ final class ShareViewController: UIViewController { try await gateway.connect( url: url, token: config.token, + bootstrapToken: nil, password: config.password, connectOptions: makeOptions("moltbot-ios"), sessionBox: nil, diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift index 9822ac1706f..dc2859d86d9 100644 --- a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift +++ b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift @@ -19,6 +19,7 @@ enum OnboardingConnectionMode: String, CaseIterable { enum OnboardingStateStore { private static let completedDefaultsKey = "onboarding.completed" + private static let firstRunIntroSeenDefaultsKey = "onboarding.first_run_intro_seen" private static let lastModeDefaultsKey = "onboarding.last_mode" private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time" @@ -39,10 +40,23 @@ enum OnboardingStateStore { defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey) } + static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool { + !defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey) + } + + static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) { + defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey) + } + static func markIncomplete(defaults: UserDefaults = .standard) { defaults.set(false, forKey: Self.completedDefaultsKey) } + static func reset(defaults: UserDefaults = .standard) { + defaults.set(false, forKey: Self.completedDefaultsKey) + defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey) + } + static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? { let raw = defaults.string(forKey: Self.lastModeDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 060b398eba4..516e7b373eb 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -6,6 +6,7 @@ import SwiftUI import UIKit private enum OnboardingStep: Int, CaseIterable { + case intro case welcome case mode case connect @@ -29,7 +30,8 @@ private enum OnboardingStep: Int, CaseIterable { var title: String { switch self { - case .welcome: "Welcome" + case .intro: "Welcome" + case .welcome: "Connect Gateway" case .mode: "Connection Mode" case .connect: "Connect" case .auth: "Authentication" @@ -38,7 +40,7 @@ private enum OnboardingStep: Int, CaseIterable { } var canGoBack: Bool { - self != .welcome && self != .success + self != .intro && self != .welcome && self != .success } } @@ -49,7 +51,7 @@ struct OnboardingWizardView: View { @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("gateway.discovery.domain") private var discoveryDomain: String = "" @AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false - @State private var step: OnboardingStep = .welcome + @State private var step: OnboardingStep @State private var selectedMode: OnboardingConnectionMode? @State private var manualHost: String = "" @State private var manualPort: Int = 18789 @@ -58,11 +60,10 @@ struct OnboardingWizardView: View { @State private var gatewayToken: String = "" @State private var gatewayPassword: String = "" @State private var connectMessage: String? - @State private var statusLine: String = "Scan the QR code from your gateway to connect." + @State private var statusLine: String = "In your OpenClaw chat, run /pair qr, then scan the code here." @State private var connectingGatewayID: String? @State private var issue: GatewayConnectionIssue = .none @State private var didMarkCompleted = false - @State private var didAutoPresentQR = false @State private var pairingRequestId: String? @State private var discoveryRestartTask: Task? @State private var showQRScanner: Bool = false @@ -74,14 +75,23 @@ struct OnboardingWizardView: View { let allowSkip: Bool let onClose: () -> Void + init(allowSkip: Bool, onClose: @escaping () -> Void) { + self.allowSkip = allowSkip + self.onClose = onClose + _step = State( + initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome) + } + private var isFullScreenStep: Bool { - self.step == .welcome || self.step == .success + self.step == .intro || self.step == .welcome || self.step == .success } var body: some View { NavigationStack { Group { switch self.step { + case .intro: + self.introStep case .welcome: self.welcomeStep case .success: @@ -293,6 +303,83 @@ struct OnboardingWizardView: View { } } + @ViewBuilder + private var introStep: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "iphone.gen3") + .font(.system(size: 60, weight: .semibold)) + .foregroundStyle(.tint) + .padding(.bottom, 18) + + Text("Welcome to OpenClaw") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + .padding(.bottom, 10) + + Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 14) { + Label("Connect to your gateway", systemImage: "link") + Label("Choose device permissions", systemImage: "hand.raised") + Label("Use OpenClaw from your phone", systemImage: "message.fill") + } + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(uiColor: .secondarySystemBackground)) + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title3.weight(.semibold)) + .foregroundStyle(.orange) + .frame(width: 24) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 6) { + Text("Security notice") + .font(.headline) + Text( + "The connected OpenClaw agent can use device capabilities you enable, such as camera, microphone, photos, contacts, calendar, and location. Continue only if you trust the gateway and agent you connect to.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(uiColor: .secondarySystemBackground)) + } + .padding(.horizontal, 24) + + Spacer() + + Button { + self.advanceFromIntro() + } label: { + Text("Continue") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + @ViewBuilder private var welcomeStep: some View { VStack(spacing: 0) { @@ -303,16 +390,37 @@ struct OnboardingWizardView: View { .foregroundStyle(.tint) .padding(.bottom, 20) - Text("Welcome") + Text("Connect Gateway") .font(.largeTitle.weight(.bold)) .padding(.bottom, 8) - Text("Connect to your OpenClaw gateway") + Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) + VStack(alignment: .leading, spacing: 8) { + Text("How to pair") + .font(.headline) + Text("In your OpenClaw chat, run") + .font(.footnote) + .foregroundStyle(.secondary) + Text("/pair qr") + .font(.system(.footnote, design: .monospaced).weight(.semibold)) + Text("Then scan the QR code here to connect this iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(uiColor: .secondarySystemBackground)) + } + .padding(.horizontal, 24) + .padding(.top, 20) + Spacer() VStack(spacing: 12) { @@ -342,8 +450,7 @@ struct OnboardingWizardView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 24) - .padding(.horizontal, 24) - .padding(.bottom, 48) + .padding(.bottom, 48) } } @@ -727,6 +834,12 @@ struct OnboardingWizardView: View { return nil } + private func advanceFromIntro() { + OnboardingStateStore.markFirstRunIntroSeen() + self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here." + self.step = .welcome + } + private func navigateBack() { guard let target = self.step.previous else { return } self.connectingGatewayID = nil @@ -775,10 +888,8 @@ struct OnboardingWizardView: View { let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword { - self.didAutoPresentQR = true - self.statusLine = "No saved pairing found. Scan QR code to connect." - self.showQRScanner = true + if !hasSavedGateway, !hasToken, !hasPassword { + self.statusLine = "No saved pairing found. In your OpenClaw chat, run /pair qr, then scan the code here." } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 3dec2fa779b..6df8c1ec510 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1008,6 +1008,7 @@ struct SettingsTab: View { // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). GatewaySettingsStore.clearLastGatewayConnection() + OnboardingStateStore.reset() // RootCanvas also short-circuits onboarding when these are true. self.onboardingComplete = false diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift index 30c014647b6..06a6a0f3ec2 100644 --- a/apps/ios/Tests/OnboardingStateStoreTests.swift +++ b/apps/ios/Tests/OnboardingStateStoreTests.swift @@ -39,6 +39,35 @@ import Testing #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) } + @Test func firstRunIntroDefaultsToVisibleThenPersists() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + #expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults)) + + OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults) + #expect(!OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults)) + } + + @Test @MainActor func resetClearsCompletionAndIntroSeen() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + OnboardingStateStore.markCompleted(mode: .homeNetwork, defaults: defaults) + OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults) + + OnboardingStateStore.reset(defaults: defaults) + + let appModel = NodeAppModel() + appModel.gatewayServerName = nil + + #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + #expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults)) + #expect(OnboardingStateStore.lastMode(defaults: defaults) == .homeNetwork) + } + private struct TestDefaults { var suiteName: String var defaults: UserDefaults From e9b1e856a0dc1a07c13188ba17136c14e2efd7da Mon Sep 17 00:00:00 2001 From: Sovtoshi <107440965+Sovtoshi-SC@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:25:48 -0600 Subject: [PATCH 007/663] chore(gitignore): add docker-compose override (#42879) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4f8abcaa94f..9d31b8c8604 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules **/node_modules/ .env +docker-compose.override.yml docker-compose.extra.yml dist pnpm-lock.yaml From af4731aa5fefcf11c7523c151062834365c0e70f Mon Sep 17 00:00:00 2001 From: ingyukoh Date: Fri, 13 Mar 2026 21:52:54 +0900 Subject: [PATCH 008/663] fix(discovery): add missing domain to wideArea Zod config schema (#35615) Merged via squash. Prepared head SHA: d81d3321b6aaf4ca4f3c63989b6b9ac431b60fbb Co-authored-by: ingyukoh <6015960+ingyukoh@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/config/config.schema-regressions.test.ts | 13 +++++++++++++ src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 2 ++ src/config/schema.labels.ts | 1 + src/config/zod-schema.ts | 1 + 6 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3792a78a34d..3bc13f724e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus. - Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec. - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. +- Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. ## 2026.3.12 diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 3e605e06c35..7a6053fd01c 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -211,4 +211,17 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + + it("accepts discovery.wideArea.domain for unicast DNS-SD", () => { + const res = validateConfigObject({ + discovery: { + wideArea: { + enabled: true, + domain: "openclaw.internal", + }, + }, + }); + + expect(res.ok).toBe(true); + }); }); diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 965eed0e55d..f74728e360b 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -296,6 +296,7 @@ const TARGET_KEYS = [ "web.reconnect.jitter", "web.reconnect.maxAttempts", "discovery", + "discovery.wideArea.domain", "discovery.wideArea.enabled", "discovery.mdns", "discovery.mdns.mode", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 20e764cbb25..7038c1effd9 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -292,6 +292,8 @@ export const FIELD_HELP: Record = { "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", "discovery.wideArea.enabled": "Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.", + "discovery.wideArea.domain": + "Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.", "discovery.mdns": "mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.", tools: diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6aa2ae40efd..774597463a8 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -654,6 +654,7 @@ export const FIELD_LABELS: Record = { discovery: "Discovery", "discovery.wideArea": "Wide-area Discovery", "discovery.wideArea.enabled": "Wide-area Discovery Enabled", + "discovery.wideArea.domain": "Wide-area Discovery Domain", "discovery.mdns": "mDNS Discovery", canvasHost: "Canvas Host", "canvasHost.enabled": "Canvas Host Enabled", diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1b24eebff4d..0064afddd20 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -596,6 +596,7 @@ export const OpenClawSchema = z wideArea: z .object({ enabled: z.boolean().optional(), + domain: z.string().optional(), }) .strict() .optional(), From 2f03de029c966cfcb8a79c5b5a30016c42649bbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 12:57:21 +0000 Subject: [PATCH 009/663] fix(node-host): harden pnpm approval binding --- CHANGELOG.md | 2 +- docs/tools/exec-approvals.md | 2 + src/node-host/invoke-system-run-plan.test.ts | 37 +++++++++++++-- src/node-host/invoke-system-run-plan.ts | 49 +++++++++++++++++--- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc13f724e6..c5f4354995f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai - Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec. - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - +- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. ## 2026.3.12 ### Changes diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 0bca1dee488..830dfa6f159 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -271,6 +271,8 @@ Approval-backed interpreter/runtime runs are intentionally conservative: - Exact argv/cwd/env context is always bound. - Direct shell script and direct runtime file forms are best-effort bound to one concrete local file snapshot. +- Common package-manager wrapper forms that still resolve to one direct local file (for example + `pnpm exec`, `pnpm node`, `npm exec`, `npx`) are unwrapped before binding. - If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command (for example package scripts, eval forms, runtime-specific loader chains, or ambiguous multi-file forms), approval-backed execution is denied instead of claiming semantic coverage it does not diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 438163d1d66..0fa76f391dc 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -40,6 +40,7 @@ type RuntimeFixture = { initialBody: string; expectedArgvIndex: number; binName?: string; + binNames?: string[]; }; function createScriptOperandFixture(tmp: string, fixture?: RuntimeFixture): ScriptOperandFixture { @@ -356,6 +357,20 @@ describe("hardenApprovedExecutionPaths", () => { initialBody: 'console.log("SAFE");\n', expectedArgvIndex: 3, }, + { + name: "pnpm reporter exec tsx file", + argv: ["pnpm", "--reporter", "silent", "exec", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 5, + }, + { + name: "pnpm reporter-equals exec tsx file", + argv: ["pnpm", "--reporter=silent", "exec", "tsx", "./run.ts"], + scriptName: "run.ts", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 4, + }, { name: "pnpm js shim exec tsx file", argv: ["./pnpm.js", "exec", "tsx", "./run.ts"], @@ -370,6 +385,22 @@ describe("hardenApprovedExecutionPaths", () => { initialBody: 'console.log("SAFE");\n', expectedArgvIndex: 4, }, + { + name: "pnpm node file", + argv: ["pnpm", "node", "./run.js"], + scriptName: "run.js", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 2, + binNames: ["pnpm", "node"], + }, + { + name: "pnpm node double-dash file", + argv: ["pnpm", "node", "--", "./run.js"], + scriptName: "run.js", + initialBody: 'console.log("SAFE");\n', + expectedArgvIndex: 3, + binNames: ["pnpm", "node"], + }, { name: "npx tsx file", argv: ["npx", "tsx", "./run.ts"], @@ -395,9 +426,9 @@ describe("hardenApprovedExecutionPaths", () => { for (const runtimeCase of mutableOperandCases) { it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { - const binNames = runtimeCase.binName - ? [runtimeCase.binName] - : ["bunx", "pnpm", "npm", "npx", "tsx"]; + const binNames = + runtimeCase.binNames ?? + (runtimeCase.binName ? [runtimeCase.binName] : ["bunx", "pnpm", "npm", "npx", "tsx"]); withFakeRuntimeBins({ binNames, run: () => { diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 867ea9f696f..3fe37676776 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -164,6 +164,26 @@ const NPM_EXEC_FLAG_OPTIONS = new Set([ "-y", ]); +const PNPM_OPTIONS_WITH_VALUE = new Set([ + "--config", + "--dir", + "--filter", + "--reporter", + "--stream", + "--test-pattern", + "--workspace-concurrency", + "-C", +]); + +const PNPM_FLAG_OPTIONS = new Set([ + "--aggregate-output", + "--color", + "--recursive", + "--silent", + "--workspace-root", + "-r", +]); + type FileOperandCollection = { hits: number[]; sawOptionValueFile: boolean; @@ -299,6 +319,8 @@ function normalizePackageManagerExecToken(token: string): string { if (!normalized) { return normalized; } + // Approval binding only promises best-effort recovery of the effective runtime + // command for common package-manager shims; it is not full package-manager semantics. return normalized.replace(/\.(?:c|m)?js$/i, ""); } @@ -315,17 +337,30 @@ function unwrapPnpmExecInvocation(argv: string[]): string[] | null { continue; } if (!token.startsWith("-")) { - if (token !== "exec" || idx + 1 >= argv.length) { - return null; + if (token === "exec") { + if (idx + 1 >= argv.length) { + return null; + } + const tail = argv.slice(idx + 1); + return tail[0] === "--" ? (tail.length > 1 ? tail.slice(1) : null) : tail; } - const tail = argv.slice(idx + 1); - return tail[0] === "--" ? (tail.length > 1 ? tail.slice(1) : null) : tail; + if (token === "node") { + const tail = argv.slice(idx + 1); + const normalizedTail = tail[0] === "--" ? tail.slice(1) : tail; + return ["node", ...normalizedTail]; + } + return null; } - if ((token === "-C" || token === "--dir" || token === "--filter") && !token.includes("=")) { - idx += 2; + const [flag] = token.toLowerCase().split("=", 2); + if (PNPM_OPTIONS_WITH_VALUE.has(flag)) { + idx += token.includes("=") ? 1 : 2; continue; } - idx += 1; + if (PNPM_FLAG_OPTIONS.has(flag)) { + idx += 1; + continue; + } + return null; } return null; } From be8d51c30135146904638dd2c76a36510078081e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 13:09:36 +0000 Subject: [PATCH 010/663] fix(node-host): harden perl approval binding --- CHANGELOG.md | 3 + src/node-host/invoke-system-run-plan.test.ts | 69 ++++++++++++++++++++ src/node-host/invoke-system-run-plan.ts | 31 +++++++++ 3 files changed, 103 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f4354995f..3eb29e1b79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. + +- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. + ## 2026.3.12 ### Changes diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 0fa76f391dc..442d2cad96b 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -625,6 +625,75 @@ describe("hardenApprovedExecutionPaths", () => { }); }); + it("rejects perl module preloads that approval cannot bind completely", () => { + withFakeRuntimeBin({ + binName: "perl", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-perl-module-preload-")); + try { + fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["perl", "-MPreload", "./safe.pl"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects perl load-path flags that can redirect module resolution after approval", () => { + withFakeRuntimeBin({ + binName: "perl", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-perl-load-path-")); + try { + fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["perl", "-Ilib", "./safe.pl"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects perl combined preload and load-path flags", () => { + withFakeRuntimeBin({ + binName: "perl", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-perl-preload-load-path-")); + try { + fs.writeFileSync(path.join(tmp, "safe.pl"), 'print "SAFE\\n";\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["perl", "-Ilib", "-MPreload", "./safe.pl"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + it("rejects shell payloads that hide mutable interpreter scripts", () => { withFakeRuntimeBin({ binName: "node", diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 3fe37676776..6d90c8a7eb6 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -135,6 +135,7 @@ const NODE_OPTIONS_WITH_FILE_VALUE = new Set([ ]); const RUBY_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-r", "--require"]); +const PERL_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-M", "-m"]); const POSIX_SHELL_OPTIONS_WITH_VALUE = new Set([ "--init-file", @@ -668,6 +669,33 @@ function hasRubyUnsafeApprovalFlag(argv: string[]): boolean { return false; } +function hasPerlUnsafeApprovalFlag(argv: string[]): boolean { + let afterDoubleDash = false; + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return false; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-I" || token === "-M" || token === "-m") { + return true; + } + if (token.startsWith("-I") || token.startsWith("-M") || token.startsWith("-m")) { + return true; + } + if (PERL_UNSAFE_APPROVAL_FLAGS.has(token)) { + return true; + } + } + return false; +} + function isMutableScriptRunner(executable: string): boolean { return GENERIC_MUTABLE_SCRIPT_RUNNERS.has(executable) || isInterpreterLikeSafeBin(executable); } @@ -709,6 +737,9 @@ function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined) if (executable === "ruby" && hasRubyUnsafeApprovalFlag(unwrapped.argv)) { return null; } + if (executable === "perl" && hasPerlUnsafeApprovalFlag(unwrapped.argv)) { + return null; + } if (!isMutableScriptRunner(executable)) { return null; } From 3cf06f7939578541120712947a7d6b30561b4477 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 13:15:46 +0000 Subject: [PATCH 011/663] docs(plugins): clarify workspace shadowing --- docs/tools/plugin.md | 18 ++++++++++++++++++ src/plugins/loader.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7dd6a045c15..5455bb2b38d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -85,6 +85,13 @@ Implications: Use allowlists and explicit install/load paths for non-bundled plugins. Treat workspace plugins as development-time code, not production defaults. +Important trust note: + +- `plugins.allow` trusts **plugin ids**, not source provenance. +- A workspace plugin with the same id as a bundled plugin intentionally shadows + the bundled copy when that workspace plugin is enabled/allowlisted. +- This is normal and useful for local development, patch testing, and hotfixes. + ## Available plugins (official) - Microsoft Teams is plugin-only as of 2026.1.15; install `@openclaw/msteams` if you use Teams. @@ -363,6 +370,14 @@ manifest. If multiple plugins resolve to the same id, the first match in the order above wins and lower-precedence copies are ignored. +That means: + +- workspace plugins intentionally shadow bundled plugins with the same id +- `plugins.allow: ["foo"]` authorizes the active `foo` plugin by id, even when + the active copy comes from the workspace instead of the bundled extension root +- if you need stricter provenance control, use explicit install/load paths and + inspect the resolved plugin source before enabling it + ### Enablement rules Enablement is resolved after discovery: @@ -372,6 +387,7 @@ Enablement is resolved after discovery: - `plugins.entries..enabled: false` disables that plugin - workspace-origin plugins are disabled by default - allowlists restrict the active set when `plugins.allow` is non-empty +- allowlists are **id-based**, not source-based - bundled plugins are disabled by default unless: - the bundled id is in the built-in default-on set, or - you explicitly enable it, or @@ -1322,6 +1338,8 @@ Plugins run in-process with the Gateway. Treat them as trusted code: - Only install plugins you trust. - Prefer `plugins.allow` allowlists. +- Remember that `plugins.allow` is id-based, so an enabled workspace plugin can + intentionally shadow a bundled plugin with the same id. - Restart the Gateway after changes. ## Testing plugins diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 95b790b69fd..031d75b31b7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1528,6 +1528,44 @@ describe("loadOpenClawPlugins", () => { expect(workspacePlugin?.status).toBe("loaded"); }); + it("lets an explicitly trusted workspace plugin shadow a bundled plugin with the same id", () => { + const bundledDir = makeTempDir(); + writePlugin({ + id: "shadowed", + body: `module.exports = { id: "shadowed", register() {} };`, + dir: bundledDir, + filename: "index.cjs", + }); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + + const workspaceDir = makeTempDir(); + const workspaceExtDir = path.join(workspaceDir, ".openclaw", "extensions", "shadowed"); + mkdirSafe(workspaceExtDir); + writePlugin({ + id: "shadowed", + body: `module.exports = { id: "shadowed", register() {} };`, + dir: workspaceExtDir, + filename: "index.cjs", + }); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir, + config: { + plugins: { + enabled: true, + allow: ["shadowed"], + }, + }, + }); + + const entries = registry.plugins.filter((entry) => entry.id === "shadowed"); + const loaded = entries.find((entry) => entry.status === "loaded"); + const overridden = entries.find((entry) => entry.status === "disabled"); + expect(loaded?.origin).toBe("workspace"); + expect(overridden?.origin).toBe("bundled"); + }); + it("warns when loaded non-bundled plugin has no install/load-path provenance", () => { useNoBundledPlugins(); const stateDir = makeTempDir(); From 0a3b9a9a090ab2ae1dbf6eb8d044e20af165c0d1 Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Fri, 13 Mar 2026 14:25:31 +0100 Subject: [PATCH 012/663] fix(ui): keep shared auth on insecure control-ui connects (#45088) Merged via squash. Prepared head SHA: 99eb3fd9281549a4e012b63eb9608dc47455ad03 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 | 2 +- ui/src/ui/gateway.node.test.ts | 72 ++++++++++++++++++++++++++++++++++ ui/src/ui/gateway.ts | 9 ++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eb29e1b79b..5fa88373053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ Docs: https://docs.openclaw.ai - Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone. - Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh. - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. - - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. +- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. ## 2026.3.12 diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts index 42d5e598245..dfc32562768 100644 --- a/ui/src/ui/gateway.node.test.ts +++ b/ui/src/ui/gateway.node.test.ts @@ -113,6 +113,12 @@ function getLatestWebSocket(): MockWebSocket { return ws; } +function stubInsecureCrypto() { + vi.stubGlobal("crypto", { + randomUUID: () => "req-insecure", + }); +} + describe("GatewayBrowserClient", () => { beforeEach(() => { const storage = createStorageMock(); @@ -176,6 +182,72 @@ describe("GatewayBrowserClient", () => { expect(signedPayload).not.toContain("stored-device-token"); }); + it("sends explicit shared token on insecure first connect without cached device fallback", async () => { + stubInsecureCrypto(); + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + token: "shared-auth-token", + }); + + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + + const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { + id?: string; + method?: string; + params?: { auth?: { token?: string; password?: string; deviceToken?: string } }; + }; + expect(connectFrame.id).toBe("req-insecure"); + expect(connectFrame.method).toBe("connect"); + expect(connectFrame.params?.auth).toEqual({ + token: "shared-auth-token", + password: undefined, + deviceToken: undefined, + }); + expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled(); + expect(signDevicePayloadMock).not.toHaveBeenCalled(); + }); + + it("sends explicit shared password on insecure first connect without cached device fallback", async () => { + stubInsecureCrypto(); + const client = new GatewayBrowserClient({ + url: "ws://gateway.example:18789", + password: "shared-password", // pragma: allowlist secret + }); + + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0)); + + const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { + id?: string; + method?: string; + params?: { auth?: { token?: string; password?: string; deviceToken?: string } }; + }; + expect(connectFrame.id).toBe("req-insecure"); + expect(connectFrame.method).toBe("connect"); + expect(connectFrame.params?.auth).toEqual({ + token: undefined, + password: "shared-password", // pragma: allowlist secret + deviceToken: undefined, + }); + expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled(); + expect(signDevicePayloadMock).not.toHaveBeenCalled(); + }); + it("uses cached device tokens only when no explicit shared auth is provided", async () => { const client = new GatewayBrowserClient({ url: "ws://127.0.0.1:18789", diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 7c958079516..6f628b619ab 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -244,8 +244,14 @@ export class GatewayBrowserClient { const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const role = "operator"; + const explicitGatewayToken = this.opts.token?.trim() || undefined; + const explicitPassword = this.opts.password?.trim() || undefined; let deviceIdentity: Awaited> | null = null; - let selectedAuth: SelectedConnectAuth = { canFallbackToShared: false }; + let selectedAuth: SelectedConnectAuth = { + authToken: explicitGatewayToken, + authPassword: explicitPassword, + canFallbackToShared: false, + }; if (isSecureContext) { deviceIdentity = await loadOrCreateDeviceIdentity(); @@ -257,7 +263,6 @@ export class GatewayBrowserClient { this.pendingDeviceTokenRetry = false; } } - const explicitGatewayToken = this.opts.token?.trim() || undefined; const authToken = selectedAuth.authToken; const deviceToken = selectedAuth.authDeviceToken ?? selectedAuth.resolvedDeviceToken; const auth = From 80e7da92ce336548ffab6ea0fc016cad460171de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 13:46:35 +0000 Subject: [PATCH 013/663] fix: stabilize macos daemon onboarding --- CHANGELOG.md | 3 +- .../onboard-non-interactive.gateway.test.ts | 35 ++++++++++++++++++- src/commands/onboard-non-interactive/local.ts | 7 +++- src/daemon/launchd.test.ts | 6 +++- src/daemon/launchd.ts | 4 ++- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa88373053..43247ddf461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ Docs: https://docs.openclaw.ai ## Unreleased - ### Changes - Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. @@ -28,7 +27,7 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - +- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. ## 2026.3.12 ### Changes diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index e7ab668ea30..f2e0724b53b 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -13,8 +13,9 @@ const gatewayClientCalls: Array<{ onClose?: (code: number, reason: string) => void; }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); +const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async () => {})); let waitForGatewayReachableMock: - | ((params: { url: string; token?: string; password?: string }) => Promise<{ + | ((params: { url: string; token?: string; password?: string; deadlineMs?: number }) => Promise<{ ok: boolean; detail?: string; }>) @@ -59,6 +60,10 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => { }; }); +vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ + installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, +})); + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"); const { resolveConfigPath } = await import("../config/config.js"); @@ -128,6 +133,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; + installGatewayDaemonNonInteractiveMock.mockClear(); }); it("writes gateway token auth into config", async () => { @@ -343,6 +349,33 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("uses a longer health deadline when daemon install was requested", async () => { + await withStateDir("state-local-daemon-health-", async (stateDir) => { + let capturedDeadlineMs: number | undefined; + waitForGatewayReachableMock = vi.fn(async (params: { deadlineMs?: number }) => { + capturedDeadlineMs = params.deadlineMs; + return { ok: true }; + }); + + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + }, + runtime, + ); + + expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); + expect(capturedDeadlineMs).toBe(45_000); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 03145ff8703..0765eb1a513 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -19,6 +19,9 @@ import { logNonInteractiveOnboardingJson } from "./local/output.js"; import { applyNonInteractiveSkillsConfig } from "./local/skills-config.js"; import { resolveNonInteractiveWorkspaceDir } from "./local/workspace.js"; +const INSTALL_DAEMON_HEALTH_DEADLINE_MS = 45_000; +const ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS = 15_000; + export async function runNonInteractiveOnboardingLocal(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -107,7 +110,9 @@ export async function runNonInteractiveOnboardingLocal(params: { const probe = await waitForGatewayReachable({ url: links.wsUrl, token: gatewayResult.gatewayToken, - deadlineMs: 15_000, + deadlineMs: opts.installDaemon + ? INSTALL_DAEMON_HEALTH_DEADLINE_MS + : ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS, }); if (!probe.ok) { const message = [ diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3acd239afe1..ba43715ba28 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -250,7 +250,7 @@ describe("launchd install", () => { }; } - it("enables service before bootstrap (clears persisted disabled state)", async () => { + it("enables service before bootstrap without self-restarting the fresh agent", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent({ env, @@ -269,9 +269,13 @@ describe("launchd install", () => { const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); + const installKickstartIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "kickstart" && c[2] === serviceId, + ); expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(enableIndex).toBeLessThan(bootstrapIndex); + expect(installKickstartIndex).toBe(-1); }); it("writes TMPDIR to LaunchAgent environment when provided", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 68ae1b43edd..0e6d8610931 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -431,7 +431,9 @@ export async function installLaunchAgent({ } throw new Error(`launchctl bootstrap failed: ${detail}`); } - await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); + // `bootstrap` already loads RunAtLoad agents. Avoid `kickstart -k` here: + // on slow macOS guests it SIGTERMs the freshly booted gateway and pushes the + // real listener startup past onboarding's health deadline. // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). writeFormattedLines( From 72b6a11a832b73c9f68db09726e291bbc358fe71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9A=B0=EC=9A=A9?= <71975659+keepitmello@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:40:32 +0900 Subject: [PATCH 014/663] fix: preserve persona and language continuity in compaction summaries (#10456) Merged via squash. Prepared head SHA: 4518fb20e1037f87493e3668621cb1a45ab8233e Co-authored-by: keepitmello <71975659+keepitmello@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 3 + src/agents/pi-embedded-runner/extensions.ts | 1 + .../compaction-instructions.test.ts | 237 ++++++++++++++++++ .../pi-extensions/compaction-instructions.ts | 68 +++++ .../compaction-safeguard-runtime.ts | 1 + .../pi-extensions/compaction-safeguard.ts | 15 +- src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + 8 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 src/agents/pi-extensions/compaction-instructions.test.ts create mode 100644 src/agents/pi-extensions/compaction-instructions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43247ddf461..34c7cab869f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Docs: https://docs.openclaw.ai ## Unreleased + ### Changes - Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. @@ -28,6 +29,8 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. +- Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. + ## 2026.3.12 ### Changes diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 251063c6f19..08c1b0a3f70 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -84,6 +84,7 @@ export function buildEmbeddedExtensionFactories(params: { contextWindowTokens: contextWindowInfo.tokens, identifierPolicy: compactionCfg?.identifierPolicy, identifierInstructions: compactionCfg?.identifierInstructions, + customInstructions: compactionCfg?.customInstructions, qualityGuardEnabled: qualityGuardCfg?.enabled ?? false, qualityGuardMaxRetries: qualityGuardCfg?.maxRetries, model: params.model, diff --git a/src/agents/pi-extensions/compaction-instructions.test.ts b/src/agents/pi-extensions/compaction-instructions.test.ts new file mode 100644 index 00000000000..a75112d07cb --- /dev/null +++ b/src/agents/pi-extensions/compaction-instructions.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_COMPACTION_INSTRUCTIONS, + resolveCompactionInstructions, + composeSplitTurnInstructions, +} from "./compaction-instructions.js"; + +describe("DEFAULT_COMPACTION_INSTRUCTIONS", () => { + it("is a non-empty string", () => { + expect(typeof DEFAULT_COMPACTION_INSTRUCTIONS).toBe("string"); + expect(DEFAULT_COMPACTION_INSTRUCTIONS.trim().length).toBeGreaterThan(0); + }); + + it("contains language preservation directive", () => { + expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("primary language"); + }); + + it("contains factual content directive", () => { + expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("factual content"); + }); + + it("does not exceed MAX_INSTRUCTION_LENGTH (800 chars)", () => { + expect(DEFAULT_COMPACTION_INSTRUCTIONS.length).toBeLessThanOrEqual(800); + }); +}); + +describe("resolveCompactionInstructions", () => { + describe("null / undefined handling", () => { + it("returns DEFAULT when both args are undefined", () => { + expect(resolveCompactionInstructions(undefined, undefined)).toBe( + DEFAULT_COMPACTION_INSTRUCTIONS, + ); + }); + + it("returns DEFAULT when both args are explicitly null (untyped JS caller)", () => { + expect( + resolveCompactionInstructions(null as unknown as undefined, null as unknown as undefined), + ).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + }); + + describe("empty and whitespace normalization", () => { + it("treats empty-string event as absent -- runtime wins", () => { + const result = resolveCompactionInstructions("", "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("treats whitespace-only event as absent -- runtime wins", () => { + const result = resolveCompactionInstructions(" ", "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("treats tab/newline-only event as absent -- runtime wins", () => { + const result = resolveCompactionInstructions("\t\n\r", "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("treats empty-string runtime as absent -- DEFAULT wins", () => { + const result = resolveCompactionInstructions(undefined, ""); + expect(result).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("treats whitespace-only runtime as absent -- DEFAULT wins", () => { + const result = resolveCompactionInstructions(undefined, " "); + expect(result).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("falls through to DEFAULT when both are empty strings", () => { + expect(resolveCompactionInstructions("", "")).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("falls through to DEFAULT when both are whitespace-only", () => { + expect(resolveCompactionInstructions(" ", "\t\n")).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("non-breaking space (\\u00A0) IS trimmed by ES2015+ trim() -- falls through", () => { + const nbsp = "\u00A0"; + const result = resolveCompactionInstructions(nbsp, "runtime"); + expect(result).toBe("runtime"); + }); + + it("KNOWN_EDGE: zero-width space (\\u200B) survives normalization -- invisible string used as instructions", () => { + const zws = "\u200B"; + const result = resolveCompactionInstructions(zws, "runtime"); + expect(result).toBe(zws); + }); + }); + + describe("precedence", () => { + it("event wins over runtime when both are non-empty", () => { + const result = resolveCompactionInstructions("event value", "runtime value"); + expect(result).toBe("event value"); + }); + + it("runtime wins when event is undefined", () => { + const result = resolveCompactionInstructions(undefined, "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("event is trimmed before use", () => { + const result = resolveCompactionInstructions(" event ", "runtime"); + expect(result).toBe("event"); + }); + + it("runtime is trimmed before use", () => { + const result = resolveCompactionInstructions(undefined, " runtime "); + expect(result).toBe("runtime"); + }); + }); + + describe("truncation at 800 chars", () => { + it("does NOT truncate string of exactly 800 chars", () => { + const exact800 = "A".repeat(800); + const result = resolveCompactionInstructions(exact800, undefined); + expect(result).toHaveLength(800); + expect(result).toBe(exact800); + }); + + it("truncates string of 801 chars to 800", () => { + const over = "B".repeat(801); + const result = resolveCompactionInstructions(over, undefined); + expect(result).toHaveLength(800); + expect(result).toBe("B".repeat(800)); + }); + + it("truncates very long string to exactly 800", () => { + const huge = "C".repeat(5000); + const result = resolveCompactionInstructions(huge, undefined); + expect(result).toHaveLength(800); + }); + + it("truncation applies AFTER trimming -- 810 raw chars with 10 leading spaces yields 800", () => { + const padded = " ".repeat(10) + "D".repeat(800); + const result = resolveCompactionInstructions(padded, undefined); + expect(result).toHaveLength(800); + expect(result).toBe("D".repeat(800)); + }); + + it("truncation applies to runtime fallback as well", () => { + const longRuntime = "R".repeat(1000); + const result = resolveCompactionInstructions(undefined, longRuntime); + expect(result).toHaveLength(800); + }); + + it("truncates by code points, not code units (emoji safe)", () => { + const emojis801 = "\u{1F600}".repeat(801); + const result = resolveCompactionInstructions(emojis801, undefined); + expect(Array.from(result)).toHaveLength(800); + }); + + it("does not split surrogate pair when cut lands inside a pair", () => { + const input = "X" + "\u{1F600}".repeat(800); + const result = resolveCompactionInstructions(input, undefined); + const codePoints = Array.from(result); + expect(codePoints).toHaveLength(800); + expect(codePoints[0]).toBe("X"); + // Every code point in the truncated result must be a complete character (no lone surrogates) + for (const cp of codePoints) { + const code = cp.codePointAt(0)!; + const isLoneSurrogate = code >= 0xd800 && code <= 0xdfff; + expect(isLoneSurrogate).toBe(false); + } + }); + }); + + describe("return type", () => { + it("always returns a string, never undefined or null", () => { + const cases: [string | undefined, string | undefined][] = [ + [undefined, undefined], + ["", ""], + [" ", " "], + [null as unknown as undefined, null as unknown as undefined], + ["valid", undefined], + [undefined, "valid"], + ]; + + for (const [event, runtime] of cases) { + const result = resolveCompactionInstructions(event, runtime); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + } + }); + }); +}); + +describe("composeSplitTurnInstructions", () => { + it("joins turn prefix, separator, and resolved instructions with double newlines", () => { + const result = composeSplitTurnInstructions("Turn prefix here", "Resolved instructions here"); + expect(result).toBe( + "Turn prefix here\n\nAdditional requirements:\n\nResolved instructions here", + ); + }); + + it("output contains the turn prefix verbatim", () => { + const prefix = "Summarize the last 5 messages."; + const result = composeSplitTurnInstructions(prefix, "Keep it short."); + expect(result).toContain(prefix); + }); + + it("output contains the resolved instructions verbatim", () => { + const instructions = "Write in Korean. Preserve persona."; + const result = composeSplitTurnInstructions("prefix", instructions); + expect(result).toContain(instructions); + }); + + it("output contains 'Additional requirements:' separator", () => { + const result = composeSplitTurnInstructions("a", "b"); + expect(result).toContain("Additional requirements:"); + }); + + it("KNOWN_EDGE: empty turnPrefix produces leading blank line", () => { + const result = composeSplitTurnInstructions("", "instructions"); + expect(result).toBe("\n\nAdditional requirements:\n\ninstructions"); + expect(result.startsWith("\n")).toBe(true); + }); + + it("KNOWN_EDGE: empty resolvedInstructions produces trailing blank area", () => { + const result = composeSplitTurnInstructions("prefix", ""); + expect(result).toBe("prefix\n\nAdditional requirements:\n\n"); + expect(result.endsWith("\n\n")).toBe(true); + }); + + it("does not deduplicate if instructions already contain 'Additional requirements:'", () => { + const instructions = "Additional requirements: keep it short."; + const result = composeSplitTurnInstructions("prefix", instructions); + const count = (result.match(/Additional requirements:/g) || []).length; + expect(count).toBe(2); + }); + + it("preserves multiline content in both inputs", () => { + const prefix = "Line 1\nLine 2"; + const instructions = "Rule A\nRule B\nRule C"; + const result = composeSplitTurnInstructions(prefix, instructions); + expect(result).toContain("Line 1\nLine 2"); + expect(result).toContain("Rule A\nRule B\nRule C"); + }); +}); diff --git a/src/agents/pi-extensions/compaction-instructions.ts b/src/agents/pi-extensions/compaction-instructions.ts new file mode 100644 index 00000000000..104cf6cb90b --- /dev/null +++ b/src/agents/pi-extensions/compaction-instructions.ts @@ -0,0 +1,68 @@ +/** + * Compaction instruction utilities. + * + * Provides default language-preservation instructions and a precedence-based + * resolver for customInstructions used during context compaction summaries. + */ + +/** + * Default instructions injected into every safeguard-mode compaction summary. + * Preserves conversation language and persona while keeping the SDK's required + * summary structure intact. + */ +export const DEFAULT_COMPACTION_INSTRUCTIONS = + "Write the summary body in the primary language used in the conversation.\n" + + "Focus on factual content: what was discussed, decisions made, and current state.\n" + + "Keep the required summary structure and section headers unchanged.\n" + + "Do not translate or alter code, file paths, identifiers, or error messages."; + +/** + * Upper bound on custom instruction length to prevent prompt bloat. + * ~800 chars ≈ ~200 tokens — keeps summarization quality stable. + */ +const MAX_INSTRUCTION_LENGTH = 800; + +function truncateUnicodeSafe(s: string, maxCodePoints: number): string { + const chars = Array.from(s); + if (chars.length <= maxCodePoints) { + return s; + } + return chars.slice(0, maxCodePoints).join(""); +} + +function normalize(s: string | undefined): string | undefined { + if (s == null) { + return undefined; + } + const trimmed = s.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Resolve compaction instructions with precedence: + * event (SDK) → runtime (config) → DEFAULT constant. + * + * Each input is normalized first (trim + empty→undefined) so that blank + * strings don't short-circuit the fallback chain. + */ +export function resolveCompactionInstructions( + eventInstructions: string | undefined, + runtimeInstructions: string | undefined, +): string { + const resolved = + normalize(eventInstructions) ?? + normalize(runtimeInstructions) ?? + DEFAULT_COMPACTION_INSTRUCTIONS; + return truncateUnicodeSafe(resolved, MAX_INSTRUCTION_LENGTH); +} + +/** + * Compose split-turn instructions by combining the SDK's turn-prefix + * instructions with the resolved compaction instructions. + */ +export function composeSplitTurnInstructions( + turnPrefixInstructions: string, + resolvedInstructions: string, +): string { + return [turnPrefixInstructions, "Additional requirements:", resolvedInstructions].join("\n\n"); +} diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts index 0180689f864..42ccb90aa49 100644 --- a/src/agents/pi-extensions/compaction-safeguard-runtime.ts +++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts @@ -7,6 +7,7 @@ export type CompactionSafeguardRuntimeValue = { contextWindowTokens?: number; identifierPolicy?: AgentCompactionIdentifierPolicy; identifierInstructions?: string; + customInstructions?: string; /** * Model to use for compaction summarization. * Passed through runtime because `ctx.model` is undefined in the compact.ts workflow diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 6012aed604d..4461b97d3e0 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -23,6 +23,10 @@ import { collectTextContentBlocks } from "../content-blocks.js"; import { wrapUntrustedPromptDataBlock } from "../sanitize-for-prompt.js"; import { repairToolUseResultPairing } from "../session-transcript-repair.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js"; +import { + composeSplitTurnInstructions, + resolveCompactionInstructions, +} from "./compaction-instructions.js"; import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js"; const log = createSubsystemLogger("compaction-safeguard"); @@ -697,7 +701,7 @@ async function readWorkspaceContextForSummary(): Promise { export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { - const { preparation, customInstructions, signal } = event; + const { preparation, customInstructions: eventInstructions, signal } = event; if (!preparation.messagesToSummarize.some(isRealConversationMessage)) { log.warn( "Compaction safeguard: cancelling compaction with no real conversation messages to summarize.", @@ -715,6 +719,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // Model resolution: ctx.model is undefined in compact.ts workflow (extensionRunner.initialize() is never called). // Fall back to runtime.model which is explicitly passed when building extension paths. const runtime = getCompactionSafeguardRuntime(ctx.sessionManager); + const customInstructions = resolveCompactionInstructions( + eventInstructions, + runtime?.customInstructions, + ); const summarizationInstructions = { identifierPolicy: runtime?.identifierPolicy, identifierInstructions: runtime?.identifierInstructions, @@ -892,7 +900,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { reserveTokens, maxChunkTokens, contextWindow: contextWindowTokens, - customInstructions: `${TURN_PREFIX_INSTRUCTIONS}\n\n${currentInstructions}`, + customInstructions: composeSplitTurnInstructions( + TURN_PREFIX_INSTRUCTIONS, + currentInstructions, + ), summarizationInstructions, previousSummary: undefined, }); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 11d1809c86a..c81cf0edbed 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -307,6 +307,8 @@ export type AgentCompactionConfig = { reserveTokensFloor?: number; /** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */ maxHistoryShare?: number; + /** Additional compaction-summary instructions that can preserve language or persona continuity. */ + customInstructions?: string; /** Preserve this many most-recent user/assistant turns verbatim in compaction summary context. */ recentTurnsPreserve?: number; /** Identifier-preservation instruction policy for compaction summaries. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 02148736e2a..dfa7e23e1c1 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -91,6 +91,7 @@ export const AgentDefaultsSchema = z keepRecentTokens: z.number().int().positive().optional(), reserveTokensFloor: z.number().int().nonnegative().optional(), maxHistoryShare: z.number().min(0.1).max(0.9).optional(), + customInstructions: z.string().optional(), identifierPolicy: z .union([z.literal("strict"), z.literal("off"), z.literal("custom")]) .optional(), From ca414735b9eef718a713b21da217aba6b8a00dad Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:44:05 -0500 Subject: [PATCH 015/663] ui: mobile navigation drawer, theme variant refinements & skills fix (#45107) thanks @BunsDev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Mobile navigation drawer with slide-over behavior at ≤1100px - Topnav & sidebar shell restructure with brand eyebrow - Chat model selection picker with optimistic caching + rollback - Nav breakpoint gap fix (769–1100px toggle visibility) - Skills page autofill pollution fix (autocomplete=off) - Delete confirm popover positioning (left/right by role) - Effective collapsed state propagation to nav items in drawer mode - Duplicate CSS selector consolidation - Session key race condition fixes in async model patching - 2 new test files + expanded test coverage (23 tests) Co-authored-by: Nova --- .gitignore | 7 +- ui/src/styles/chat/grouped.css | 9 +- ui/src/styles/chat/layout.css | 20 + ui/src/styles/layout.css | 656 +++++++++--------- ui/src/styles/layout.mobile.css | 274 ++++++-- ui/src/ui/app-chat.test.ts | 58 +- ui/src/ui/app-chat.ts | 35 +- ui/src/ui/app-render.helpers.ts | 143 +++- ui/src/ui/app-render.ts | 305 ++++---- ui/src/ui/app-view-state.ts | 4 + ui/src/ui/app.ts | 5 + ui/src/ui/chat/grouped-render.ts | 12 +- ui/src/ui/chat/slash-command-executor.ts | 10 +- ui/src/ui/navigation.browser.test.ts | 169 +++++ ui/src/ui/views/agents-panels-tools-skills.ts | 2 + ui/src/ui/views/chat.test.ts | 271 ++++++++ ui/src/ui/views/config.browser.test.ts | 9 + ui/src/ui/views/config.ts | 62 +- ui/src/ui/views/skills.ts | 2 + 19 files changed, 1473 insertions(+), 580 deletions(-) diff --git a/.gitignore b/.gitignore index 9d31b8c8604..0eabcb6843c 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md .gitignore test/config-form.analyze.telegram.test.ts ui/src/ui/theme-variants.browser.test.ts -ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png -ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png -ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png +ui/src/ui/__screenshots__ +ui/src/ui/views/__screenshots__ +ui/.vitest-attachments +docs/superpowers diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index cd482f46f7c..9955557b886 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -401,7 +401,6 @@ img.chat-avatar { .chat-delete-confirm { position: absolute; bottom: calc(100% + 6px); - left: 0; background: var(--card, #1a1a1a); border: 1px solid var(--border, rgba(255, 255, 255, 0.1)); border-radius: var(--radius-md, 8px); @@ -412,6 +411,14 @@ img.chat-avatar { animation: scale-in 0.15s ease-out; } +.chat-delete-confirm--left { + right: 0; +} + +.chat-delete-confirm--right { + left: 0; +} + .chat-delete-confirm__text { margin: 0 0 8px; font-size: 13px; diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 6d12698d6b2..536acddd29e 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -670,6 +670,18 @@ max-width: 300px; } +.chat-controls__session-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.chat-controls__model { + min-width: 170px; + max-width: 320px; +} + .chat-controls__thinking { display: flex; align-items: center; @@ -760,6 +772,10 @@ text-overflow: ellipsis; } +.chat-controls__model select { + max-width: 320px; +} + .chat-controls__thinking { display: flex; align-items: center; @@ -812,6 +828,10 @@ .chat-controls__session { min-width: 120px; } + + .chat-controls__model { + min-width: 150px; + } } /* Chat loading skeleton */ diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 2114ea2565b..12f22aef21d 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -5,8 +5,8 @@ .shell { --shell-pad: 16px; --shell-gap: 16px; - --shell-nav-width: 220px; - --shell-nav-rail-width: 72px; + --shell-nav-width: 288px; + --shell-nav-rail-width: 78px; --shell-topbar-height: 52px; --shell-focus-duration: 200ms; --shell-focus-ease: var(--ease-out); @@ -15,7 +15,7 @@ grid-template-columns: var(--shell-nav-width) minmax(0, 1fr); grid-template-rows: var(--shell-topbar-height) 1fr; grid-template-areas: - "topbar topbar" + "nav topbar" "nav content"; gap: 0; animation: dashboard-enter 0.3s var(--ease-out); @@ -50,6 +50,7 @@ } .shell--onboarding { + grid-template-columns: 0 minmax(0, 1fr); grid-template-rows: 0 1fr; } @@ -57,6 +58,10 @@ display: none; } +.shell--onboarding .shell-nav { + display: none; +} + .shell--onboarding .content { padding-top: 0; } @@ -79,21 +84,42 @@ top: 0; z-index: 40; display: flex; - justify-content: space-between; align-items: center; - gap: 16px; - padding: 0 20px; - height: var(--shell-topbar-height); - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--bg) 85%, transparent); + padding: 0 24px; + min-height: 58px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px) saturate(1.6); -webkit-backdrop-filter: blur(12px) saturate(1.6); } -.topbar-left { +.topnav-shell { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + min-height: var(--shell-topbar-height); + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.topbar-nav-toggle { + display: none; +} + +.topnav-shell__actions { display: flex; align-items: center; gap: 12px; + flex-shrink: 0; +} + +.topnav-shell__content { + min-width: 0; + flex: 1; } .topbar .nav-collapse-toggle { @@ -112,49 +138,36 @@ height: 20px; } -/* Brand */ -.brand { +.topnav-shell .dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.topnav-shell .dashboard-header__breadcrumb { display: flex; align-items: center; gap: 8px; + min-width: 0; + overflow: hidden; + font-size: 13px; } -.brand-logo { - width: 26px; - height: 26px; - flex-shrink: 0; -} - -.brand-logo img { - width: 100%; - height: 100%; - object-fit: contain; -} - -.brand-text { - display: flex; - flex-direction: column; - gap: 0; -} - -.brand-title { - font-size: 15px; - font-weight: 700; - letter-spacing: -0.03em; - line-height: 1.1; - color: var(--text-strong); -} - -.brand-sub { - font-size: 9px; - font-weight: 500; +.topnav-shell .dashboard-header__breadcrumb-link, +.topnav-shell .dashboard-header__breadcrumb-sep { color: var(--muted); - letter-spacing: 0.06em; - text-transform: uppercase; - line-height: 1; } -/* Topbar status */ +.topnav-shell .dashboard-header__breadcrumb-current { + color: var(--text-strong); + font-weight: 650; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .topbar-status { display: flex; align-items: center; @@ -188,15 +201,15 @@ font-size: 13px; } -/* Topbar search trigger */ .topbar-search { display: inline-flex; align-items: center; gap: 12px; - padding: 7px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); + min-height: 38px; + padding: 0 14px; + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); color: var(--muted); font-size: 13px; cursor: pointer; @@ -204,12 +217,12 @@ border-color var(--duration-fast) ease, background var(--duration-fast) ease, color var(--duration-fast) ease; - min-width: 180px; + min-width: 200px; } .topbar-search:hover { - border-color: var(--border-strong); - background: var(--bg-hover); + border-color: color-mix(in srgb, var(--border-strong) 90%, transparent); + background: color-mix(in srgb, var(--bg-hover) 84%, transparent); color: var(--text); } @@ -242,9 +255,9 @@ align-items: center; gap: 2px; padding: 3px; - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--bg-elevated) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-elevated) 78%, transparent); } .topbar-theme-mode__btn { @@ -292,19 +305,22 @@ } /* =========================================== - Navigation Sidebar (shadcn-inspired) + Navigation Sidebar =========================================== */ -/* Sidebar wrapper – occupies the "nav" grid area */ .shell-nav { grid-area: nav; display: flex; - min-height: 0; + min-height: 100%; overflow: hidden; + border-right: 1px solid color-mix(in srgb, var(--border) 74%, transparent); transition: width var(--shell-focus-duration) var(--shell-focus-ease); } -/* The sidebar panel itself */ +.shell-nav-backdrop { + display: none; +} + .sidebar { display: flex; flex-direction: column; @@ -312,67 +328,103 @@ min-height: 0; min-width: 0; overflow: hidden; - background: var(--bg); + background: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%); } :root[data-theme-mode="light"] .sidebar { - background: var(--panel); + background: color-mix(in srgb, var(--panel) 98%, white 2%); +} + +.sidebar-shell { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + padding: 14px 14px 12px; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; } -/* Collapsed: icon-only rail */ .sidebar--collapsed { width: var(--shell-nav-rail-width); min-width: var(--shell-nav-rail-width); flex: 0 0 var(--shell-nav-rail-width); - border-right: 1px solid color-mix(in srgb, var(--border-strong) 72%, transparent); } -/* Header: brand + collapse toggle */ -.sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 14px 14px 6px; +.sidebar-shell__header, +.sidebar-shell__footer { flex-shrink: 0; } -.sidebar--collapsed .sidebar-header { - justify-content: center; - padding: 12px 10px 6px; +.sidebar-shell__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 0; + padding: 0 8px 18px; +} + +.sidebar-shell__body { + min-height: 0; + flex: 1; + display: flex; +} + +.sidebar-shell__footer { + padding: 12px 8px 0; + border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); } -/* Brand lockup */ .sidebar-brand { display: flex; align-items: center; - gap: 8px; + gap: 10px; min-width: 0; } .sidebar-brand__logo { - width: 22px; - height: 22px; + width: 32px; + height: 32px; flex-shrink: 0; - border-radius: 6px; + border-radius: 10px; + box-shadow: 0 8px 18px color-mix(in srgb, black 12%, transparent); +} + +.sidebar-brand__copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.sidebar-brand__eyebrow { + font-size: 10px; + line-height: 1.1; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--muted); + text-transform: uppercase; } .sidebar-brand__title { - font-size: 14px; + font-size: 15px; + line-height: 1.1; font-weight: 700; - letter-spacing: -0.025em; + letter-spacing: -0.03em; color: var(--text-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* Scrollable nav body */ .sidebar-nav { flex: 1; overflow-y: auto; overflow-x: hidden; - padding: 4px 8px; + padding: 0; scrollbar-width: none; } @@ -380,177 +432,31 @@ display: none; } -.sidebar--collapsed .sidebar-nav { - padding: 4px 8px; - display: flex; - flex-direction: column; - gap: 24px; -} - -/* Collapsed sidebar: centre icons, hide text */ -.sidebar--collapsed .nav-group__label { - display: none; -} - -.sidebar--collapsed .nav-group { - gap: 4px; - margin-bottom: 0; -} - -/* In collapsed sidebar, always show nav items (icon-only) regardless of group collapse state */ -.sidebar--collapsed .nav-group--collapsed .nav-group__items { - display: grid; -} - -.sidebar--collapsed .nav-item { - justify-content: center; - width: 44px; - height: 42px; - padding: 0; - margin: 0 auto; - border-radius: 16px; -} - -.sidebar--collapsed .nav-item__icon { - width: 18px; - height: 18px; - opacity: 0.78; -} - -.sidebar--collapsed .nav-item__icon svg { - width: 18px; - height: 18px; -} - -.sidebar--collapsed .nav-item__text { - display: none; -} - -.sidebar--collapsed .nav-item__external-icon { - display: none; -} - -/* Footer: docs link + version */ -.sidebar-footer { - flex-shrink: 0; - padding: 8px; - border-top: 1px solid var(--border); -} - -.sidebar--collapsed .sidebar-footer { - padding: 12px 8px 10px; -} - -.sidebar-footer__docs-block { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.sidebar--collapsed .sidebar-footer__docs-block { - align-items: center; - gap: 10px; -} - -.sidebar--collapsed .sidebar-footer .nav-item { - justify-content: center; - width: 44px; - height: 44px; - padding: 0; -} - -.sidebar-version { - display: flex; - align-items: center; - justify-content: center; - padding: 4px 10px; -} - -.sidebar-version__text { - font-size: 11px; - color: var(--muted); - font-weight: 500; - letter-spacing: 0.02em; -} - -.sidebar-version__dot { - width: 8px; - height: 8px; - border-radius: var(--radius-full); - background: color-mix(in srgb, var(--accent) 78%, white 22%); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); - opacity: 1; - margin: 0 auto; -} - -/* Drag-to-resize handle */ -.sidebar-resizer { - width: 3px; - cursor: col-resize; - flex-shrink: 0; - background: transparent; - transition: background var(--duration-fast) ease; - position: relative; -} - -.sidebar-resizer::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 3px; - background: transparent; - transition: background var(--duration-fast) ease; -} - -.sidebar-resizer:hover::after { - background: var(--accent); - opacity: 0.35; -} - -.sidebar-resizer:active::after { - background: var(--accent); - opacity: 0.6; -} - -/* Shell-level collapsed / focus overrides */ -.shell--nav-collapsed .shell-nav { - width: var(--shell-nav-rail-width); - min-width: var(--shell-nav-rail-width); -} - -.shell--chat-focus .shell-nav { - width: 0; - min-width: 0; - overflow: hidden; - pointer-events: none; - opacity: 0; -} - -/* Nav collapse toggle */ .nav-collapse-toggle { - width: 28px; - height: 28px; + width: 36px; + height: 36px; display: flex; align-items: center; justify-content: center; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--border-strong) 68%, transparent); + border-radius: 999px; cursor: pointer; transition: background var(--duration-fast) ease, border-color var(--duration-fast) ease, - color var(--duration-fast) ease; + color var(--duration-fast) ease, + transform var(--duration-fast) ease; margin-bottom: 0; color: var(--muted); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); } .nav-collapse-toggle:hover { - background: var(--bg-hover); + background: color-mix(in srgb, var(--bg-hover) 90%, transparent); + border-color: color-mix(in srgb, var(--border-strong) 88%, transparent); color: var(--text); + transform: translateY(-1px); } .nav-collapse-toggle__icon { @@ -572,81 +478,65 @@ stroke-linejoin: round; } -.nav-collapse-toggle:hover .nav-collapse-toggle__icon { - color: inherit; -} - -/* Nav groups */ -.nav-group { - margin-bottom: 12px; +.nav-section { display: grid; - gap: 1px; + gap: 6px; + margin-bottom: 16px; } -.nav-group:last-child { +.nav-section:last-child { margin-bottom: 0; } -.nav-group__items { +.nav-section__items { display: grid; - gap: 1px; + gap: 4px; } -.nav-group--collapsed .nav-group__items { +.nav-section--collapsed .nav-section__items { display: none; } -.nav-group__label { +.nav-section__label { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; - padding: 5px 10px; - font-size: 10px; - font-weight: 600; - color: var(--muted); - margin-bottom: 2px; + padding: 0 12px; + min-height: 28px; background: transparent; border: none; + border-radius: 10px; + color: var(--muted); cursor: pointer; text-align: left; - text-transform: uppercase; - letter-spacing: 0.06em; - border-radius: var(--radius-sm); transition: color var(--duration-fast) ease, background var(--duration-fast) ease; } -.nav-group__label:hover { +.nav-section__label:hover { color: var(--text); - background: var(--bg-hover); + background: color-mix(in srgb, var(--bg-hover) 72%, transparent); } -.nav-group__label--static { - cursor: default; +.nav-section__label-text { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.nav-group__label--static:hover { - color: var(--muted); - background: transparent; -} - -.nav-group__label-text { - flex: 1; -} - -.nav-group__chevron { +.nav-section__chevron { display: inline-flex; align-items: center; justify-content: center; - font-size: 10px; opacity: 0.5; transition: transform var(--duration-fast) ease; } -.nav-group__chevron svg { +.nav-section__chevron svg { width: 12px; height: 12px; stroke: currentColor; @@ -656,19 +546,19 @@ stroke-linejoin: round; } -.nav-group--collapsed .nav-group__chevron { +.nav-section--collapsed .nav-section__chevron { transform: rotate(-90deg); } -/* Nav items */ .nav-item { position: relative; display: flex; align-items: center; justify-content: flex-start; - gap: 8px; - padding: 7px 10px; - border-radius: var(--radius-md); + gap: 10px; + min-height: 38px; + padding: 0 12px; + border-radius: 12px; border: 1px solid transparent; background: transparent; color: var(--muted); @@ -677,23 +567,26 @@ transition: border-color var(--duration-fast) ease, background var(--duration-fast) ease, - color var(--duration-fast) ease; + color var(--duration-fast) ease, + transform var(--duration-fast) ease; } .nav-item__icon { - width: 15px; - height: 15px; + width: 16px; + height: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - opacity: 0.6; - transition: opacity var(--duration-fast) ease; + opacity: 0.72; + transition: + opacity var(--duration-fast) ease, + color var(--duration-fast) ease; } .nav-item__icon svg { - width: 15px; - height: 15px; + width: 16px; + height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5px; @@ -703,25 +596,29 @@ .nav-item__text { font-size: 13px; - font-weight: 450; + font-weight: 550; white-space: nowrap; } .nav-item:hover { color: var(--text); - background: var(--bg-hover); + background: color-mix(in srgb, var(--bg-hover) 84%, transparent); + border-color: color-mix(in srgb, var(--border) 72%, transparent); text-decoration: none; } .nav-item:hover .nav-item__icon { - opacity: 0.9; + opacity: 1; } .nav-item.active, .nav-item--active { color: var(--text-strong); - background: var(--accent-subtle); - border-color: color-mix(in srgb, var(--accent) 15%, transparent); + background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%); + border-color: color-mix(in srgb, var(--accent) 18%, transparent); + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 10%, transparent), + 0 12px 24px color-mix(in srgb, black 10%, transparent); } .nav-item.active .nav-item__icon, @@ -730,40 +627,171 @@ color: var(--accent); } +.sidebar--collapsed .sidebar-shell { + padding: 12px 8px 10px; +} + +.sidebar--collapsed .sidebar-shell__header { + justify-content: center; + align-items: center; + gap: 0; + padding: 0 2px 16px; +} + +.sidebar--collapsed .sidebar-nav { + padding: 0; +} + +.sidebar--collapsed .nav-section { + gap: 6px; + margin-bottom: 16px; +} + +.sidebar--collapsed .nav-item { + justify-content: center; + width: 44px; + min-height: 44px; + padding: 0; + margin: 0 auto; + border-radius: 16px; + border-color: transparent; + box-shadow: none; +} + +.sidebar--collapsed .nav-item__icon { + width: 18px; + height: 18px; +} + +.sidebar--collapsed .nav-item__icon svg { + width: 18px; + height: 18px; +} + +.sidebar--collapsed .nav-item__text, +.sidebar--collapsed .nav-item__external-icon { + display: none; +} + .sidebar--collapsed .nav-item--active::before, .sidebar--collapsed .nav-item.active::before { content: ""; position: absolute; - left: 6px; - top: 11px; - bottom: 11px; - width: 2px; + left: 8px; + top: 10px; + bottom: 10px; + width: 3px; border-radius: 999px; - background: color-mix(in srgb, var(--accent) 78%, transparent); + background: color-mix(in srgb, #2de3d1 86%, transparent); + box-shadow: 0 0 14px color-mix(in srgb, #2de3d1 34%, transparent); } .sidebar--collapsed .nav-item.active, .sidebar--collapsed .nav-item--active { - background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%); - border-color: color-mix(in srgb, var(--accent) 12%, var(--border) 88%); - box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, #0b2f34 84%, var(--bg-elevated) 16%) 0%, + color-mix(in srgb, #081f25 90%, var(--bg) 10%) 100% + ); + border-color: color-mix(in srgb, #1ed2c2 18%, var(--border) 82%); + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 8%, transparent), + 0 10px 20px color-mix(in srgb, black 18%, transparent); } .sidebar--collapsed .nav-collapse-toggle { - width: 44px; - height: 34px; - margin-bottom: 0; - border-color: color-mix(in srgb, var(--border-strong) 74%, transparent); - border-radius: var(--radius-full); + width: 42px; + height: 42px; + border-color: color-mix(in srgb, var(--border) 82%, transparent); background: color-mix(in srgb, var(--bg-elevated) 92%, transparent); box-shadow: - inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent), + inset 0 1px 0 color-mix(in srgb, white 8%, transparent), 0 8px 18px color-mix(in srgb, black 16%, transparent); } -.sidebar--collapsed .nav-collapse-toggle:hover { - border-color: color-mix(in srgb, var(--border-strong) 72%, transparent); - background: color-mix(in srgb, var(--bg-elevated) 96%, transparent); +.sidebar--collapsed .sidebar-brand__logo { + width: 34px; + height: 34px; + border-radius: 12px; + box-shadow: + 0 10px 20px color-mix(in srgb, black 20%, transparent), + inset 0 1px 0 color-mix(in srgb, white 10%, transparent); +} + +.sidebar-utility-group { + display: grid; + gap: 8px; +} + +.sidebar-utility-link { + min-height: 42px; +} + +.sidebar-version { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 40px; + padding: 0 12px; + border-radius: 14px; + background: color-mix(in srgb, var(--bg-elevated) 72%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); +} + +.sidebar-version__label { + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.sidebar-version__text { + font-size: 12px; + color: var(--text); + font-weight: 600; +} + +.sidebar-version__dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--accent) 78%, white 22%); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); + opacity: 1; + margin: 0 auto; +} + +.sidebar--collapsed .sidebar-shell__footer { + padding: 8px 0 2px; +} + +.sidebar--collapsed .sidebar-utility-group { + justify-items: center; + gap: 6px; +} + +.sidebar--collapsed .sidebar-version { + width: 44px; + min-height: 44px; + padding: 0; + justify-content: center; + border-radius: 16px; +} + +.shell--nav-collapsed .shell-nav { + width: var(--shell-nav-rail-width); + min-width: var(--shell-nav-rail-width); +} + +.shell--chat-focus .shell-nav { + width: 0; + min-width: 0; + overflow: hidden; + pointer-events: none; + opacity: 0; + border-right-width: 0; } .nav-item__external-icon { @@ -955,12 +983,6 @@ "content"; } - .nav-group { - grid-auto-flow: column; - grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); - margin-bottom: 0; - } - .grid-cols-2, .grid-cols-3 { grid-template-columns: 1fr; diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index b871fe1d440..3c929435a7b 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -2,61 +2,131 @@ Mobile Layout =========================================== */ -/* Tablet and smaller: collapse the left nav into a horizontal rail. */ +/* Tablet and smaller: switch the left nav to a slide-over drawer. */ @media (max-width: 1100px) { .shell, .shell--nav-collapsed { grid-template-columns: minmax(0, 1fr); - grid-template-rows: var(--shell-topbar-height) auto minmax(0, 1fr); + grid-template-rows: var(--shell-topbar-height) minmax(0, 1fr); grid-template-areas: "topbar" - "nav" "content"; } .shell--chat-focus { - grid-template-rows: var(--shell-topbar-height) 0 minmax(0, 1fr); + grid-template-rows: var(--shell-topbar-height) minmax(0, 1fr); } .shell-nav, .shell--nav-collapsed .shell-nav { - width: auto; + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 70; + width: min(86vw, 320px); min-width: 0; - border-bottom: 1px solid var(--border); + border-right: none; + box-shadow: 0 30px 80px color-mix(in srgb, black 40%, transparent); + transform: translateX(-100%); + opacity: 0; + pointer-events: none; + transition: + transform var(--shell-focus-duration) var(--shell-focus-ease), + opacity var(--shell-focus-duration) var(--shell-focus-ease); + } + + .shell--nav-collapsed:not(.shell--nav-drawer-open) .shell-nav { + width: var(--shell-nav-rail-width); + transform: translateX(0); + opacity: 1; + pointer-events: auto; + box-shadow: none; + } + + .shell--nav-drawer-open .shell-nav, + .shell--nav-collapsed.shell--nav-drawer-open .shell-nav { + transform: translateX(0); + opacity: 1; + pointer-events: auto; + } + + .shell-nav-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 65; + border: 0; + background: color-mix(in srgb, black 52%, transparent); + opacity: 0; + pointer-events: none; + transition: opacity var(--shell-focus-duration) var(--shell-focus-ease); + } + + .shell--nav-drawer-open .shell-nav-backdrop { + opacity: 1; + pointer-events: auto; + } + + /* Show the hamburger toggle at the same breakpoint where the drawer takes over. */ + .topbar-nav-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-elevated) 80%, transparent); + color: var(--muted); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); } .sidebar, .sidebar--collapsed { - width: auto; + width: 100%; min-width: 0; flex: 1 1 auto; - flex-direction: row; - align-items: center; + flex-direction: column; + align-items: stretch; border-right: none; } - .sidebar-header, - .sidebar--collapsed .sidebar-header { - justify-content: flex-start; - padding: 8px 10px; - flex: 0 0 auto; + .sidebar-shell, + .sidebar--collapsed .sidebar-shell { + padding: 18px 16px 14px; + border-radius: 0; } - .sidebar-brand { + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar-shell, + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .sidebar-shell { + padding: 12px 8px 10px; + } + + .sidebar-shell__header { + min-height: 0; + padding: 0 4px 16px; + } + + .sidebar-shell__header .nav-collapse-toggle { display: none; } + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar-shell__header { + justify-content: center; + align-items: center; + gap: 0; + padding: 0 2px 16px; + } + .sidebar-nav, .sidebar--collapsed .sidebar-nav { flex: 1 1 auto; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - gap: 8px; - padding: 8px 10px 8px 0; - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; + display: block; + padding: 0; + overflow-x: hidden; + overflow-y: auto; scrollbar-width: none; } @@ -65,29 +135,36 @@ display: none; } - .nav-group, - .nav-group__items, - .sidebar--collapsed .nav-group, - .sidebar--collapsed .nav-group__items { - display: contents; + .nav-section, + .sidebar--collapsed .nav-section { + display: grid; + margin-bottom: 16px; } - .nav-group { - margin-bottom: 0; - } - - .sidebar-nav .nav-group__label { - display: none; + .sidebar-nav .nav-section__label, + .sidebar--collapsed .nav-section__label { + display: flex; } .nav-item, .sidebar--collapsed .nav-item { margin: 0; - padding: 8px 14px; + min-height: 40px; + padding: 0 12px; font-size: 13px; - border-radius: var(--radius-md); + border-radius: 12px; white-space: nowrap; flex: 0 0 auto; + width: auto; + } + + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item { + justify-content: center; + width: 44px; + min-height: 44px; + padding: 0; + margin: 0 auto; + border-radius: 16px; } .sidebar--collapsed .nav-item--active::before, @@ -95,14 +172,53 @@ content: none; } - .sidebar-footer, - .sidebar--collapsed .sidebar-footer { + .sidebar--collapsed .nav-item__text, + .sidebar--collapsed .nav-item__external-icon { + display: inline-flex; + } + + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item__text, + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item__external-icon { display: none; } + + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item--active::before, + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .nav-item.active::before { + content: ""; + position: absolute; + left: 8px; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 999px; + background: color-mix(in srgb, #2de3d1 86%, transparent); + box-shadow: 0 0 14px color-mix(in srgb, #2de3d1 34%, transparent); + } + + .sidebar--collapsed .sidebar-shell__footer { + padding: 12px 8px 0; + } + + .sidebar--collapsed .sidebar-version { + width: auto; + min-height: 40px; + padding: 0 12px; + } + + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .sidebar-shell__footer { + padding: 8px 0 2px; + } + + .shell--nav-collapsed:not(.shell--nav-drawer-open) .sidebar--collapsed .sidebar-version { + width: 44px; + min-height: 44px; + padding: 0; + justify-content: center; + } } /* Mobile-specific styles */ -@media (max-width: 600px) { +@media (max-width: 768px) { .shell { --shell-pad: 8px; --shell-gap: 8px; @@ -111,24 +227,40 @@ /* Topbar */ .topbar { padding: 10px 12px; - gap: 8px; - flex-direction: row; + min-height: auto; + } + + .topnav-shell { flex-wrap: wrap; - justify-content: space-between; - align-items: center; + gap: 10px; } - .brand { - flex: 1; + .topnav-shell__actions { min-width: 0; + flex: 1 1 auto; + justify-content: space-between; + gap: 10px; + align-items: stretch; } - .brand-title { - font-size: 14px; + .topnav-shell__content { + order: 3; + width: 100%; } - .brand-sub { - display: none; + .topbar-nav-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 38px; + height: 38px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-elevated) 80%, transparent); + color: var(--muted); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); } .topbar-status { @@ -137,6 +269,15 @@ flex-wrap: nowrap; } + .topbar-search { + min-width: 0; + flex: 1; + } + + .topbar-theme-mode { + flex-shrink: 0; + } + .topbar-status .pill { padding: 4px 8px; font-size: 11px; @@ -151,25 +292,23 @@ display: none; } - .shell-nav { - border-bottom-width: 0; + .shell-nav, + .shell--nav-collapsed .shell-nav { + width: min(92vw, 320px); } - .sidebar-header { - padding: 6px 8px; + .shell--nav-collapsed:not(.shell--nav-drawer-open) .shell-nav { + width: 78px; } - .sidebar-nav { - gap: 6px; - padding: 6px 8px 6px 0; + .sidebar-shell, + .sidebar--collapsed .sidebar-shell { + padding: 16px 14px 12px; } - .nav-item { - padding: 6px 10px; + .nav-item, + .sidebar--collapsed .nav-item { font-size: 12px; - border-radius: var(--radius-md); - white-space: nowrap; - flex-shrink: 0; } /* Content */ @@ -177,6 +316,19 @@ display: none; } + .content--chat .content-header { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .content--chat .content-header > div:first-child, + .content--chat .page-meta, + .content--chat .chat-controls { + width: 100%; + } + .content { padding: 4px 4px 16px; gap: 12px; diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 1fcdf14db7f..9a3e86d375d 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshChatAvatar, type ChatHost } from "./app-chat.ts"; +import { handleSendChat, refreshChatAvatar, type ChatHost } from "./app-chat.ts"; function makeHost(overrides?: Partial): ChatHost { return { @@ -19,7 +19,11 @@ function makeHost(overrides?: Partial): ChatHost { basePath: "", hello: null, chatAvatarUrl: null, + chatModelOverrides: {}, + chatModelsLoading: false, + chatModelCatalog: [], refreshSessionsAfterChat: new Set(), + updateComplete: Promise.resolve(), ...overrides, }; } @@ -63,3 +67,55 @@ describe("refreshChatAvatar", () => { expect(host.chatAvatarUrl).toBeNull(); }); }); + +describe("handleSendChat", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps slash-command model changes in sync with the chat header cache", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }) as unknown as typeof fetch, + ); + const request = vi.fn(async (method: string, _params?: unknown) => { + if (method === "sessions.patch") { + return { ok: true, key: "main" }; + } + if (method === "chat.history") { + return { messages: [], thinkingLevel: null }; + } + if (method === "sessions.list") { + return { + ts: 0, + path: "", + count: 0, + defaults: { model: "gpt-5", contextTokens: null }, + sessions: [], + }; + } + if (method === "models.list") { + return { + models: [{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }], + }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + sessionKey: "main", + chatMessage: "/model gpt-5-mini", + }); + + await handleSendChat(host); + + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "main", + model: "gpt-5-mini", + }); + expect(host.chatModelOverrides.main).toBe("gpt-5-mini"); + }); +}); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 05f6aa8c9e2..c877b4c5a5d 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -6,9 +6,11 @@ import type { OpenClawApp } from "./app.ts"; import { executeSlashCommand } from "./chat/slash-command-executor.ts"; import { parseSlashCommand } from "./chat/slash-commands.ts"; import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts"; +import { loadModels } from "./controllers/models.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; +import type { ModelCatalogEntry } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -27,6 +29,10 @@ export type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; + chatModelOverrides: Record; + chatModelsLoading: boolean; + chatModelCatalog: ModelCatalogEntry[]; + updateComplete?: Promise; refreshSessionsAfterChat: Set; /** Callback for slash-command side effects that need app-level access. */ onSlashAction?: (action: string) => void; @@ -295,12 +301,20 @@ async function dispatchSlashCommand( return; } - const result = await executeSlashCommand(host.client, host.sessionKey, name, args); + const targetSessionKey = host.sessionKey; + const result = await executeSlashCommand(host.client, targetSessionKey, name, args); if (result.content) { injectCommandResult(host, result.content); } + if (result.sessionPatch && "model" in result.sessionPatch) { + host.chatModelOverrides = { + ...host.chatModelOverrides, + [targetSessionKey]: result.sessionPatch.model ?? null, + }; + } + if (result.action === "refresh") { await refreshChat(host); } @@ -341,16 +355,31 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool loadSessions(host as unknown as OpenClawApp, { activeMinutes: 0, limit: 0, - includeGlobal: false, - includeUnknown: false, + includeGlobal: true, + includeUnknown: true, }), refreshChatAvatar(host), + refreshChatModels(host), ]); if (opts?.scheduleScroll !== false) { scheduleChatScroll(host as unknown as Parameters[0]); } } +async function refreshChatModels(host: ChatHost) { + if (!host.client || !host.connected) { + host.chatModelsLoading = false; + host.chatModelCatalog = []; + return; + } + host.chatModelsLoading = true; + try { + host.chatModelCatalog = await loadModels(host.client); + } finally { + host.chatModelsLoading = false; + } +} + export const flushChatQueueForEvent = flushChatQueue; type SessionDefaultsSnapshot = { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 0a2003fac34..0ebafc22d4d 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -12,7 +12,7 @@ import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; -import type { SessionsListResult } from "./types.ts"; +import type { ModelCatalogEntry, SessionsListResult } from "./types.ts"; type SessionDefaultsSnapshot = { mainSessionKey?: string; @@ -49,10 +49,10 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) }); } -export function renderTab(state: AppViewState, tab: Tab) { +export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) { const href = pathForTab(tab, state.basePath); const isActive = state.tab === tab; - const collapsed = state.settings.navCollapsed; + const collapsed = opts?.collapsed ?? state.settings.navCollapsed; return html` + ${modelSelect} `; } @@ -316,11 +318,139 @@ async function refreshSessionOptions(state: AppViewState) { await loadSessions(state as unknown as Parameters[0], { activeMinutes: 0, limit: 0, - includeGlobal: false, - includeUnknown: false, + includeGlobal: true, + includeUnknown: true, }); } +function resolveActiveSessionRow(state: AppViewState) { + return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey); +} + +function resolveModelOverrideValue(state: AppViewState): string { + // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. + const cached = state.chatModelOverrides[state.sessionKey]; + if (typeof cached === "string") { + return cached.trim(); + } + // cached === null means explicitly cleared to default. + if (cached === null) { + return ""; + } + // No local override recorded yet — fall back to server data. + const activeRow = resolveActiveSessionRow(state); + if (activeRow) { + return typeof activeRow.model === "string" ? activeRow.model.trim() : ""; + } + return ""; +} + +function resolveDefaultModelValue(state: AppViewState): string { + const model = state.sessionsResult?.defaults?.model; + return typeof model === "string" ? model.trim() : ""; +} + +function buildChatModelOptions( + catalog: ModelCatalogEntry[], + currentOverride: string, + defaultModel: string, +): Array<{ value: string; label: string }> { + const seen = new Set(); + const options: Array<{ value: string; label: string }> = []; + const addOption = (value: string, label?: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return; + } + const key = trimmed.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + options.push({ value: trimmed, label: label ?? trimmed }); + }; + + for (const entry of catalog) { + const provider = entry.provider?.trim(); + addOption(entry.id, provider ? `${entry.id} · ${provider}` : entry.id); + } + + if (currentOverride) { + addOption(currentOverride); + } + if (defaultModel) { + addOption(defaultModel); + } + return options; +} + +function renderChatModelSelect(state: AppViewState) { + const currentOverride = resolveModelOverrideValue(state); + const defaultModel = resolveDefaultModelValue(state); + const options = buildChatModelOptions( + state.chatModelCatalog ?? [], + currentOverride, + defaultModel, + ); + const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default model"; + const busy = + state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; + const disabled = + !state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client; + return html` + + `; +} + +async function switchChatModel(state: AppViewState, nextModel: string) { + if (!state.client || !state.connected) { + return; + } + const currentOverride = resolveModelOverrideValue(state); + if (currentOverride === nextModel) { + return; + } + const targetSessionKey = state.sessionKey; + const prevOverride = state.chatModelOverrides[targetSessionKey]; + state.lastError = null; + // Write the override cache immediately so the picker stays in sync during the RPC round-trip. + state.chatModelOverrides = { + ...state.chatModelOverrides, + [targetSessionKey]: nextModel || null, + }; + try { + await state.client.request("sessions.patch", { + key: targetSessionKey, + model: nextModel || null, + }); + await refreshSessionOptions(state); + } catch (err) { + // Roll back so the picker reflects the actual server model. + state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride }; + state.lastError = `Failed to set model: ${String(err)}`; + } +} + /* ── Channel display labels ────────────────────────────── */ const CHANNEL_LABELS: Record = { bluebubbles: "iMessage", @@ -504,6 +634,9 @@ export function resolveSessionOptionGroups( }; for (const row of rows) { + if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) { + continue; + } if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) { continue; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 74644f07708..b1ddf9e323c 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -264,33 +264,6 @@ type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; -const NAV_WIDTH_MIN = 200; -const NAV_WIDTH_MAX = 400; - -function handleNavResizeStart(e: MouseEvent, state: AppViewState) { - e.preventDefault(); - const startX = e.clientX; - const startWidth = state.settings.navWidth; - - const onMove = (ev: MouseEvent) => { - const delta = ev.clientX - startX; - const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta))); - state.applySettings({ ...state.settings, navWidth: next }); - }; - - const onUp = () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); -} - function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -330,6 +303,8 @@ export function renderApp(state: AppViewState) { const chatDisabledReason = state.connected ? null : t("chat.disconnected"); const isChat = state.tab === "chat"; const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); + const navDrawerOpen = Boolean(state.navDrawerOpen && !chatFocus && !state.onboarding); + const navCollapsed = Boolean(state.settings.navCollapsed && !navDrawerOpen); const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const assistantAvatarUrl = resolveAssistantAvatarUrl(state); const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; @@ -423,144 +398,164 @@ export function renderApp(state: AppViewState) { }, })}
+
- - -
- ${renderTopbarThemeModeToggle(state)} +
+ +
+ +
+
+ +
+ ${renderTopbarThemeModeToggle(state)} +
+
-
+
+
- - - ${ - !state.settings.navCollapsed && !chatFocus - ? html` - - ` - : nothing - } +
${ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index b659c195754..ad2910625b6 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -71,11 +71,15 @@ export type AppViewState = { fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; + chatModelOverrides: Record; + chatModelsLoading: boolean; + chatModelCatalog: ModelCatalogEntry[]; chatQueue: ChatQueueItem[]; chatManualRefreshInFlight: boolean; nodesLoading: boolean; nodes: Array>; chatNewMessagesBelow: boolean; + navDrawerOpen: boolean; sidebarOpen: boolean; sidebarContent: string | null; sidebarError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7f936722ca5..1b3971a41f6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -158,9 +158,13 @@ export class OpenClawApp extends LitElement { @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; + @state() chatModelOverrides: Record = {}; + @state() chatModelsLoading = false; + @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; @state() chatAttachments: ChatAttachment[] = []; @state() chatManualRefreshInFlight = false; + @state() navDrawerOpen = false; onSlashAction?: (action: string) => void; @@ -541,6 +545,7 @@ export class OpenClawApp extends LitElement { setTab(next: Tab) { setTabInternal(this as unknown as Parameters[0], next); + this.navDrawerOpen = false; } setTheme(next: ThemeName, context?: Parameters[2]) { diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 9a7f7d2eeb2..6b584be512b 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -174,7 +174,11 @@ export function renderMessageGroup( ${timestamp} ${renderMessageMeta(meta)} ${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing} - ${opts.onDelete ? renderDeleteButton(opts.onDelete) : nothing} + ${ + opts.onDelete + ? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right") + : nothing + } @@ -312,6 +316,8 @@ function extractGroupText(group: MessageGroup): string { const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm"; +type DeleteConfirmSide = "left" | "right"; + function shouldSkipDeleteConfirm(): boolean { try { return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1"; @@ -320,7 +326,7 @@ function shouldSkipDeleteConfirm(): boolean { } } -function renderDeleteButton(onDelete: () => void) { +function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) { return html` - ` - : nothing - } +
+ + + + + + props.onSearchChange((e.target as HTMLInputElement).value)} + /> + ${ + props.searchQuery + ? html` + + ` + : nothing + } +
` : nothing diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index ad0f4ee63c0..b9338971c8e 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -61,6 +61,8 @@ export function renderSkills(props: SkillsProps) { .value=${props.filter} @input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)} placeholder="Search skills" + autocomplete="off" + name="skills-filter" />
${filtered.length} shown
From 55e79adf6916ffed4b745744793f1502338f1b92 Mon Sep 17 00:00:00 2001 From: Max aka Mosheh Date: Fri, 13 Mar 2026 17:09:51 +0200 Subject: [PATCH 016/663] fix: resolve target agent workspace for cross-agent subagent spawns (#40176) Merged via squash. Prepared head SHA: 2378e40383f194557c582b8e28976e57dfe03e8a Co-authored-by: moshehbenavraham <17122072+moshehbenavraham@users.noreply.github.com> Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Reviewed-by: @mcaxtr --- CHANGELOG.md | 1 + src/agents/spawned-context.test.ts | 30 ++- src/agents/spawned-context.ts | 14 +- src/agents/subagent-spawn.ts | 7 +- src/agents/subagent-spawn.workspace.test.ts | 192 ++++++++++++++++++++ 5 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/agents/subagent-spawn.workspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c7cab869f..4b1cf0c9e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -333,6 +333,7 @@ Docs: https://docs.openclaw.ai - Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. - Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym. - Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz. +- Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham. ## 2026.3.7 diff --git a/src/agents/spawned-context.test.ts b/src/agents/spawned-context.test.ts index 964bf47a789..3f163eb3030 100644 --- a/src/agents/spawned-context.test.ts +++ b/src/agents/spawned-context.test.ts @@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => { }); describe("resolveSpawnedWorkspaceInheritance", () => { + const config = { + agents: { + list: [ + { id: "main", workspace: "/tmp/workspace-main" }, + { id: "ops", workspace: "/tmp/workspace-ops" }, + ], + }, + }; + it("prefers explicit workspaceDir when provided", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: "agent:main:subagent:parent", explicitWorkspaceDir: " /tmp/explicit ", }); expect(resolved).toBe("/tmp/explicit"); }); + it("prefers targetAgentId over requester session agent for cross-agent spawns", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + targetAgentId: "ops", + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-ops"); + }); + + it("falls back to requester session agent when targetAgentId is missing", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-main"); + }); + it("returns undefined for missing requester context", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: undefined, explicitWorkspaceDir: undefined, }); diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 32a4d299e74..d0919c86baa 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -58,6 +58,7 @@ export function mapToolContextToSpawnedRunMetadata( export function resolveSpawnedWorkspaceInheritance(params: { config: OpenClawConfig; + targetAgentId?: string; requesterSessionKey?: string; explicitWorkspaceDir?: string | null; }): string | undefined { @@ -65,12 +66,13 @@ export function resolveSpawnedWorkspaceInheritance(params: { if (explicit) { return explicit; } - const requesterAgentId = params.requesterSessionKey - ? parseAgentSessionKey(params.requesterSessionKey)?.agentId - : undefined; - return requesterAgentId - ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId)) - : undefined; + // For cross-agent spawns, use the target agent's workspace instead of the requester's. + const agentId = + params.targetAgentId ?? + (params.requesterSessionKey + ? parseAgentSessionKey(params.requesterSessionKey)?.agentId + : undefined); + return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined; } export function resolveIngressWorkspaceOverrideForSpawnedRun( diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index a4a6229c715..1750d948e6c 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -576,8 +576,11 @@ export async function spawnSubagentDirect( ...toolSpawnMetadata, workspaceDir: resolveSpawnedWorkspaceInheritance({ config: cfg, - requesterSessionKey: requesterInternalKey, - explicitWorkspaceDir: toolSpawnMetadata.workspaceDir, + targetAgentId, + // For cross-agent spawns, ignore the caller's inherited workspace; + // let targetAgentId resolve the correct workspace instead. + explicitWorkspaceDir: + targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir, }), }); const spawnLineagePatchError = await patchChildSession({ diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts new file mode 100644 index 00000000000..fef6bc7515c --- /dev/null +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { spawnSubagentDirect } from "./subagent-spawn.js"; + +type TestAgentConfig = { + id?: string; + workspace?: string; + subagents?: { + allowAgents?: string[]; + }; +}; + +type TestConfig = { + agents?: { + list?: TestAgentConfig[]; + }; +}; + +const hoisted = vi.hoisted(() => ({ + callGatewayMock: vi.fn(), + configOverride: {} as Record, + registerSubagentRunMock: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => hoisted.configOverride, + }; +}); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => "", + getOAuthProviders: () => [], +})); + +vi.mock("./subagent-registry.js", () => ({ + countActiveRunsForSession: () => 0, + registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args), +})); + +vi.mock("./subagent-announce.js", () => ({ + buildSubagentSystemPrompt: () => "system-prompt", +})); + +vi.mock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, +})); + +vi.mock("./model-selection.js", () => ({ + resolveSubagentSpawnModelSelection: () => undefined, +})); + +vi.mock("./sandbox/runtime-status.js", () => ({ + resolveSandboxRuntimeStatus: () => ({ sandboxed: false }), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), +})); + +vi.mock("../utils/delivery-context.js", () => ({ + normalizeDeliveryContext: (value: unknown) => value, +})); + +vi.mock("./tools/sessions-helpers.js", () => ({ + resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), + resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", +})); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentConfig: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId), + resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ?? + `/tmp/workspace-${agentId}`, +})); + +function createConfigOverride(overrides?: Record) { + return { + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + }, + ], + }, + ...overrides, + }; +} + +function setupGatewayMock() { + hoisted.callGatewayMock.mockImplementation( + async (opts: { method?: string; params?: Record }) => { + if (opts.method === "sessions.patch") { + return { ok: true }; + } + if (opts.method === "sessions.delete") { + return { ok: true }; + } + if (opts.method === "agent") { + return { runId: "run-1" }; + } + return {}; + }, + ); +} + +function getRegisteredRun() { + return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as + | Record + | undefined; +} + +describe("spawnSubagentDirect workspace inheritance", () => { + beforeEach(() => { + hoisted.callGatewayMock.mockClear(); + hoisted.registerSubagentRunMock.mockClear(); + hoisted.configOverride = createConfigOverride(); + setupGatewayMock(); + }); + + it("uses the target agent workspace for cross-agent spawns", async () => { + hoisted.configOverride = createConfigOverride({ + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + subagents: { + allowAgents: ["ops"], + }, + }, + { + id: "ops", + workspace: "/tmp/workspace-ops", + }, + ], + }, + }); + + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "ops", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/workspace-ops", + }); + }); + + it("preserves the inherited workspace for same-agent spawns", async () => { + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "main", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/requester-workspace", + }); + }); +}); From 394fd87c2c491790c1f79d6eb37ba40de7178cbc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 15:37:21 +0000 Subject: [PATCH 017/663] fix: clarify gated core tool warnings --- CHANGELOG.md | 1 + src/agents/tool-policy-pipeline.test.ts | 25 +++++++++++++++++++++ src/agents/tool-policy-pipeline.ts | 30 ++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1cf0c9e98..cae46427d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. - Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello. +- Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin. ## 2026.3.12 diff --git a/src/agents/tool-policy-pipeline.test.ts b/src/agents/tool-policy-pipeline.test.ts index 9d0a9d5846f..70d4301d42a 100644 --- a/src/agents/tool-policy-pipeline.test.ts +++ b/src/agents/tool-policy-pipeline.test.ts @@ -45,6 +45,31 @@ describe("tool-policy-pipeline", () => { expect(warnings[0]).toContain("unknown entries (wat)"); }); + test("warns gated core tools as unavailable instead of plugin-only unknowns", () => { + const warnings: string[] = []; + const tools = [{ name: "exec" }] as unknown as DummyTool[]; + applyToolPolicyPipeline({ + // oxlint-disable-next-line typescript/no-explicit-any + tools: tools as any, + // oxlint-disable-next-line typescript/no-explicit-any + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + steps: [ + { + policy: { allow: ["apply_patch"] }, + label: "tools.profile (coding)", + stripPluginOnlyAllowlist: true, + }, + ], + }); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("unknown entries (apply_patch)"); + expect(warnings[0]).toContain( + "shipped core tools but unavailable in the current runtime/provider/model/config", + ); + expect(warnings[0]).not.toContain("unless the plugin is enabled"); + }); + test("applies allowlist filtering when core tools are explicitly listed", () => { const tools = [{ name: "exec" }, { name: "process" }] as unknown as DummyTool[]; const filtered = applyToolPolicyPipeline({ diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index d3304a020d6..70a7bddaf29 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -1,5 +1,6 @@ import { filterToolsByPolicy } from "./pi-tools.policy.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; +import { isKnownCoreToolId } from "./tool-catalog.js"; import { buildPluginToolGroups, expandPolicyWithPluginGroups, @@ -91,9 +92,15 @@ export function applyToolPolicyPipeline(params: { const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames); if (resolved.unknownAllowlist.length > 0) { const entries = resolved.unknownAllowlist.join(", "); - const suffix = resolved.strippedAllowlist - ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement." - : "These entries won't match any tool unless the plugin is enabled."; + const gatedCoreEntries = resolved.unknownAllowlist.filter((entry) => + isKnownCoreToolId(entry), + ); + const otherEntries = resolved.unknownAllowlist.filter((entry) => !isKnownCoreToolId(entry)); + const suffix = describeUnknownAllowlistSuffix({ + strippedAllowlist: resolved.strippedAllowlist, + hasGatedCoreEntries: gatedCoreEntries.length > 0, + hasOtherEntries: otherEntries.length > 0, + }); params.warn( `tools: ${step.label} allowlist contains unknown entries (${entries}). ${suffix}`, ); @@ -106,3 +113,20 @@ export function applyToolPolicyPipeline(params: { } return filtered; } + +function describeUnknownAllowlistSuffix(params: { + strippedAllowlist: boolean; + hasGatedCoreEntries: boolean; + hasOtherEntries: boolean; +}): string { + const preface = params.strippedAllowlist + ? "Ignoring allowlist so core tools remain available." + : ""; + const detail = + params.hasGatedCoreEntries && params.hasOtherEntries + ? "Some entries are shipped core tools but unavailable in the current runtime/provider/model/config; other entries won't match any tool unless the plugin is enabled." + : params.hasGatedCoreEntries + ? "These entries are shipped core tools but unavailable in the current runtime/provider/model/config." + : "These entries won't match any tool unless the plugin is enabled."; + return preface ? `${preface} ${detail}` : detail; +} From 202765c8109b2c2320610958cf65795b19fade8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:22:13 +0000 Subject: [PATCH 018/663] fix: quiet local windows gateway auth noise --- CHANGELOG.md | 1 + src/gateway/call.test.ts | 14 ++++++++++++++ src/gateway/call.ts | 20 +++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cae46427d1e..2a8270dd154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. +- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 87590e58d49..e4d8d28f562 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -14,6 +14,7 @@ let lastClientOptions: { password?: string; tlsFingerprint?: string; scopes?: string[]; + deviceIdentity?: unknown; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string) => void; } | null = null; @@ -197,6 +198,19 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); + it("does not attach device identity for local loopback shared-token auth", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ + method: "health", + token: "explicit-token", + }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + }); + it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 31d11ac14b9..8e8f449fc59 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,6 +81,22 @@ export type GatewayConnectionDetails = { message: string; }; +function shouldAttachDeviceIdentityForGatewayCall(params: { + url: string; + token?: string; + password?: string; +}): boolean { + if (!(params.token || params.password)) { + return true; + } + try { + const parsed = new URL(params.url); + return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); + } catch { + return true; + } +} + export type ExplicitGatewayAuth = { token?: string; password?: string; @@ -818,7 +834,9 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: loadOrCreateDeviceIdentity(), + deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({ url, token, password }) + ? loadOrCreateDeviceIdentity() + : undefined, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => { From f4ed3170832db59a9761178494126ca3307ec804 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:24:58 +0000 Subject: [PATCH 019/663] refactor: deduplicate acpx availability checks --- extensions/acpx/src/runtime.ts | 155 +++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 54 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index b0f166584d5..ad3fb23c709 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -13,7 +13,7 @@ import type { } from "openclaw/plugin-sdk/acpx"; import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx"; import { toAcpMcpServers, type ResolvedAcpxPluginConfig } from "./config.js"; -import { checkAcpxVersion } from "./ensure.js"; +import { checkAcpxVersion, type AcpxVersionCheckResult } from "./ensure.js"; import { parseJsonLines, parsePromptEventLine, @@ -51,6 +51,28 @@ const ACPX_CAPABILITIES: AcpRuntimeCapabilities = { controls: ["session/set_mode", "session/set_config_option", "session/status"], }; +type AcpxHealthCheckResult = + | { + ok: true; + versionCheck: Extract; + } + | { + ok: false; + failure: + | { + kind: "version-check"; + versionCheck: Extract; + } + | { + kind: "help-check"; + result: Awaited>; + } + | { + kind: "exception"; + error: unknown; + }; + }; + function formatPermissionModeGuidance(): string { return "Configure plugins.entries.acpx.config.permissionMode to one of: approve-reads, approve-all, deny-all."; } @@ -165,35 +187,71 @@ export class AcpxRuntime implements AcpRuntime { ); } - async probeAvailability(): Promise { - const versionCheck = await checkAcpxVersion({ + private async checkVersion(): Promise { + return await checkAcpxVersion({ command: this.config.command, cwd: this.config.cwd, expectedVersion: this.config.expectedVersion, stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, spawnOptions: this.spawnCommandOptions, }); + } + + private async runHelpCheck(): Promise>> { + return await spawnAndCollect( + { + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + }, + this.spawnCommandOptions, + ); + } + + private async checkHealth(): Promise { + const versionCheck = await this.checkVersion(); if (!versionCheck.ok) { - this.healthy = false; - return; + return { + ok: false, + failure: { + kind: "version-check", + versionCheck, + }, + }; } try { - const result = await spawnAndCollect( - { - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + const result = await this.runHelpCheck(); + if (result.error != null || (result.code ?? 0) !== 0) { + return { + ok: false, + failure: { + kind: "help-check", + result, + }, + }; + } + return { + ok: true, + versionCheck, + }; + } catch (error) { + return { + ok: false, + failure: { + kind: "exception", + error, }, - this.spawnCommandOptions, - ); - this.healthy = result.error == null && (result.code ?? 0) === 0; - } catch { - this.healthy = false; + }; } } + async probeAvailability(): Promise { + const result = await this.checkHealth(); + this.healthy = result.ok; + } + async ensureSession(input: AcpRuntimeEnsureInput): Promise { const sessionName = asTrimmedString(input.sessionKey); if (!sessionName) { @@ -494,14 +552,9 @@ export class AcpxRuntime implements AcpRuntime { } async doctor(): Promise { - const versionCheck = await checkAcpxVersion({ - command: this.config.command, - cwd: this.config.cwd, - expectedVersion: this.config.expectedVersion, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, - spawnOptions: this.spawnCommandOptions, - }); - if (!versionCheck.ok) { + const result = await this.checkHealth(); + if (!result.ok && result.failure.kind === "version-check") { + const { versionCheck } = result.failure; this.healthy = false; const details = [ versionCheck.expectedVersion ? `expected=${versionCheck.expectedVersion}` : null, @@ -516,20 +569,12 @@ export class AcpxRuntime implements AcpRuntime { }; } - try { - const result = await spawnAndCollect( - { - command: this.config.command, - args: ["--help"], - cwd: this.config.cwd, - stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, - }, - this.spawnCommandOptions, - ); - if (result.error) { - const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd); + if (!result.ok && result.failure.kind === "help-check") { + const { result: helpResult } = result.failure; + this.healthy = false; + if (helpResult.error) { + const spawnFailure = resolveSpawnFailure(helpResult.error, this.config.cwd); if (spawnFailure === "missing-command") { - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", @@ -538,42 +583,44 @@ export class AcpxRuntime implements AcpRuntime { }; } if (spawnFailure === "missing-cwd") { - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: `ACP runtime working directory does not exist: ${this.config.cwd}`, }; } - this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: result.error.message, - details: [String(result.error)], + message: helpResult.error.message, + details: [String(helpResult.error)], }; } - if ((result.code ?? 0) !== 0) { - this.healthy = false; - return { - ok: false, - code: "ACP_BACKEND_UNAVAILABLE", - message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`, - }; - } - this.healthy = true; return { - ok: true, - message: `acpx command available (${this.config.command}, version ${versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, + ok: false, + code: "ACP_BACKEND_UNAVAILABLE", + message: + helpResult.stderr.trim() || `acpx exited with code ${helpResult.code ?? "unknown"}`, }; - } catch (error) { + } + + if (!result.ok) { this.healthy = false; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", - message: error instanceof Error ? error.message : String(error), + message: + result.failure.error instanceof Error + ? result.failure.error.message + : String(result.failure.error), }; } + + this.healthy = true; + return { + ok: true, + message: `acpx command available (${this.config.command}, version ${result.versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`, + }; } async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise { From a37e25fa21aba307bc7dd3846a888989be43d0c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:25:54 +0000 Subject: [PATCH 020/663] refactor: deduplicate media store writes --- src/media/store.ts | 71 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/src/media/store.ts b/src/media/store.ts index ceb346a1f94..32acd951d32 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -255,6 +255,48 @@ export type SavedMedia = { contentType?: string; }; +function buildSavedMediaId(params: { + baseId: string; + ext: string; + originalFilename?: string; +}): string { + if (!params.originalFilename) { + return params.ext ? `${params.baseId}${params.ext}` : params.baseId; + } + + const base = path.parse(params.originalFilename).name; + const sanitized = sanitizeFilename(base); + return sanitized + ? `${sanitized}---${params.baseId}${params.ext}` + : `${params.baseId}${params.ext}`; +} + +function buildSavedMediaResult(params: { + dir: string; + id: string; + size: number; + contentType?: string; +}): SavedMedia { + return { + id: params.id, + path: path.join(params.dir, params.id), + size: params.size, + contentType: params.contentType, + }; +} + +async function writeSavedMediaBuffer(params: { + dir: string; + id: string; + buffer: Buffer; +}): Promise { + const dest = path.join(params.dir, params.id); + await retryAfterRecreatingDir(params.dir, () => + fs.writeFile(dest, params.buffer, { mode: MEDIA_FILE_MODE }), + ); + return dest; +} + export type SaveMediaSourceErrorCode = | "invalid-path" | "not-found" @@ -321,20 +363,19 @@ export async function saveMediaSource( filePath: source, }); const ext = extensionForMime(mime) ?? path.extname(new URL(source).pathname); - const id = ext ? `${baseId}${ext}` : baseId; + const id = buildSavedMediaId({ baseId, ext }); const finalDest = path.join(dir, id); await fs.rename(tempDest, finalDest); - return { id, path: finalDest, size, contentType: mime }; + return buildSavedMediaResult({ dir, id, size, contentType: mime }); } // local path try { const { buffer, stat } = await readLocalFileSafely({ filePath: source, maxBytes: MAX_BYTES }); const mime = await detectMime({ buffer, filePath: source }); const ext = extensionForMime(mime) ?? path.extname(source); - const id = ext ? `${baseId}${ext}` : baseId; - const dest = path.join(dir, id); - await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); - return { id, path: dest, size: stat.size, contentType: mime }; + const id = buildSavedMediaId({ baseId, ext }); + await writeSavedMediaBuffer({ dir, id, buffer }); + return buildSavedMediaResult({ dir, id, size: stat.size, contentType: mime }); } catch (err) { if (err instanceof SafeOpenError) { throw toSaveMediaSourceError(err); @@ -359,19 +400,7 @@ export async function saveMediaBuffer( const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const mime = await detectMime({ buffer, headerMime: contentType }); const ext = headerExt ?? extensionForMime(mime) ?? ""; - - let id: string; - if (originalFilename) { - // Embed original name: {sanitized}---{uuid}.ext - const base = path.parse(originalFilename).name; - const sanitized = sanitizeFilename(base); - id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`; - } else { - // Legacy: just UUID - id = ext ? `${uuid}${ext}` : uuid; - } - - const dest = path.join(dir, id); - await retryAfterRecreatingDir(dir, () => fs.writeFile(dest, buffer, { mode: MEDIA_FILE_MODE })); - return { id, path: dest, size: buffer.byteLength, contentType: mime }; + const id = buildSavedMediaId({ baseId: uuid, ext, originalFilename }); + await writeSavedMediaBuffer({ dir, id, buffer }); + return buildSavedMediaResult({ dir, id, size: buffer.byteLength, contentType: mime }); } From 501837058cb811d0f310b2473b2bfd18d2b562ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:26:42 +0000 Subject: [PATCH 021/663] refactor: share outbound media payload sequencing --- .../plugins/outbound/direct-text-media.ts | 56 +++++++++++++------ src/channels/plugins/outbound/telegram.ts | 27 ++++----- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 9617798325d..ea813fcf75b 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -28,34 +28,58 @@ type SendPayloadAdapter = Pick< "sendMedia" | "sendText" | "chunker" | "textChunkLimit" >; +export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { + return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; +} + +export async function sendPayloadMediaSequence(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; +}): Promise { + let lastResult: TResult | undefined; + for (let i = 0; i < params.mediaUrls.length; i += 1) { + const mediaUrl = params.mediaUrls[i]; + if (!mediaUrl) { + continue; + } + lastResult = await params.send({ + text: i === 0 ? params.text : "", + mediaUrl, + index: i, + isFirst: i === 0, + }); + } + return lastResult; +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; adapter: SendPayloadAdapter; }): Promise { const text = params.ctx.payload.text ?? ""; - const urls = params.ctx.payload.mediaUrls?.length - ? params.ctx.payload.mediaUrls - : params.ctx.payload.mediaUrl - ? [params.ctx.payload.mediaUrl] - : []; + const urls = resolvePayloadMediaUrls(params.ctx.payload); if (!text && urls.length === 0) { return { channel: params.channel, messageId: "" }; } if (urls.length > 0) { - let lastResult = await params.adapter.sendMedia!({ - ...params.ctx, + const lastResult = await sendPayloadMediaSequence({ text, - mediaUrl: urls[0], + mediaUrls: urls, + send: async ({ text, mediaUrl }) => + await params.adapter.sendMedia!({ + ...params.ctx, + text, + mediaUrl, + }), }); - for (let i = 1; i < urls.length; i++) { - lastResult = await params.adapter.sendMedia!({ - ...params.ctx, - text: "", - mediaUrl: urls[i], - }); - } - return lastResult; + return lastResult ?? { channel: params.channel, messageId: "" }; } const limit = params.adapter.textChunkLimit; const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 8af1b5831ee..c96a44a7047 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -8,6 +8,7 @@ import { } from "../../../telegram/outbound-params.js"; import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; +import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js"; type TelegramSendFn = typeof sendMessageTelegram; type TelegramSendOpts = Parameters[2]; @@ -55,11 +56,7 @@ export async function sendTelegramPayloadMessages(params: { const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = params.payload.text ?? ""; - const mediaUrls = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; + const mediaUrls = resolvePayloadMediaUrls(params.payload); const payloadOpts = { ...params.baseOpts, quoteText, @@ -73,16 +70,16 @@ export async function sendTelegramPayloadMessages(params: { } // Telegram allows reply_markup on media; attach buttons only to the first send. - let finalResult: Awaited> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await params.send(params.to, isFirst ? text : "", { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } + const finalResult = await sendPayloadMediaSequence({ + text, + mediaUrls, + send: async ({ text, mediaUrl, isFirst }) => + await params.send(params.to, text, { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }), + }); return finalResult ?? { messageId: "unknown", chatId: params.to }; } From 3f37afd18cd9083dac4c709acb44c11b73325a0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:27:18 +0000 Subject: [PATCH 022/663] refactor: extract acpx event builders --- .../acpx/src/runtime-internals/events.ts | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index f83f4ddabb9..f0326bbe938 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -162,6 +162,39 @@ function resolveTextChunk(params: { }; } +function createTextDeltaEvent(params: { + content: string | null | undefined; + stream: "output" | "thought"; + tag?: AcpSessionUpdateTag; +}): AcpRuntimeEvent | null { + if (params.content == null || params.content.length === 0) { + return null; + } + return { + type: "text_delta", + text: params.content, + stream: params.stream, + ...(params.tag ? { tag: params.tag } : {}), + }; +} + +function createToolCallEvent(params: { + payload: Record; + tag: AcpSessionUpdateTag; +}): AcpRuntimeEvent { + const title = asTrimmedString(params.payload.title) || "tool call"; + const status = asTrimmedString(params.payload.status); + const toolCallId = asOptionalString(params.payload.toolCallId); + return { + type: "tool_call", + text: status ? `${title} (${status})` : title, + tag: params.tag, + ...(toolCallId ? { toolCallId } : {}), + ...(status ? { status } : {}), + title, + }; +} + export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { const trimmed = line.trim(); if (!trimmed) { @@ -187,57 +220,28 @@ export function parsePromptEventLine(line: string): AcpRuntimeEvent | null { const tag = structured.tag; switch (type) { - case "text": { - const content = asString(payload.content); - if (content == null || content.length === 0) { - return null; - } - return { - type: "text_delta", - text: content, + case "text": + return createTextDeltaEvent({ + content: asString(payload.content), stream: "output", - ...(tag ? { tag } : {}), - }; - } - case "thought": { - const content = asString(payload.content); - if (content == null || content.length === 0) { - return null; - } - return { - type: "text_delta", - text: content, + tag, + }); + case "thought": + return createTextDeltaEvent({ + content: asString(payload.content), stream: "thought", - ...(tag ? { tag } : {}), - }; - } - case "tool_call": { - const title = asTrimmedString(payload.title) || "tool call"; - const status = asTrimmedString(payload.status); - const toolCallId = asOptionalString(payload.toolCallId); - return { - type: "tool_call", - text: status ? `${title} (${status})` : title, + tag, + }); + case "tool_call": + return createToolCallEvent({ + payload, tag: (tag ?? "tool_call") as AcpSessionUpdateTag, - ...(toolCallId ? { toolCallId } : {}), - ...(status ? { status } : {}), - title, - }; - } - case "tool_call_update": { - const title = asTrimmedString(payload.title) || "tool call"; - const status = asTrimmedString(payload.status); - const toolCallId = asOptionalString(payload.toolCallId); - const text = status ? `${title} (${status})` : title; - return { - type: "tool_call", - text, + }); + case "tool_call_update": + return createToolCallEvent({ + payload, tag: (tag ?? "tool_call_update") as AcpSessionUpdateTag, - ...(toolCallId ? { toolCallId } : {}), - ...(status ? { status } : {}), - title, - }; - } + }); case "agent_message_chunk": return resolveTextChunk({ payload, From 261a40dae12c181ce78b5572dfb94ca63e652886 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:28:31 +0000 Subject: [PATCH 023/663] fix: narrow acpx health failure handling --- extensions/acpx/src/runtime.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index ad3fb23c709..e55ef360424 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -606,13 +606,16 @@ export class AcpxRuntime implements AcpRuntime { if (!result.ok) { this.healthy = false; + const failure = result.failure; return { ok: false, code: "ACP_BACKEND_UNAVAILABLE", message: - result.failure.error instanceof Error - ? result.failure.error.message - : String(result.failure.error), + failure.kind === "exception" + ? failure.error instanceof Error + ? failure.error.message + : String(failure.error) + : "acpx backend unavailable", }; } From 41718404a1ddcce7726fbcbae278fc46ff31f959 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:41:22 +0000 Subject: [PATCH 024/663] ci: opt workflows into Node 24 action runtime --- .github/workflows/auto-response.yml | 3 +++ .github/workflows/ci.yml | 3 +++ .github/workflows/codeql.yml | 3 +++ .github/workflows/docker-release.yml | 1 + .github/workflows/install-smoke.yml | 3 +++ .github/workflows/labeler.yml | 3 +++ .github/workflows/openclaw-npm-release.yml | 1 + .github/workflows/sandbox-common-smoke.yml | 3 +++ .github/workflows/stale.yml | 3 +++ .github/workflows/workflow-sanity.yml | 3 +++ 10 files changed, 26 insertions(+) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index d9d810bffa7..c3aca216775 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -8,6 +8,9 @@ on: pull_request_target: types: [labeled] +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: {} jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9038096a488..18c6f14fdaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). # Lint and format always run. Fail-safe: if detection fails, run everything. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1d8e473af4f..e01f7185a37 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,6 +7,9 @@ concurrency: group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: actions: read contents: read diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 3ad4b539311..0486bc76760 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -18,6 +18,7 @@ concurrency: cancel-in-progress: false env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index ca04748f9bf..26b5de0e2b6 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -10,6 +10,9 @@ concurrency: group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: docs-scope: runs-on: blacksmith-16vcpu-ubuntu-2404 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8de54a416f8..716f39ea24c 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -16,6 +16,9 @@ on: required: false default: "50" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: {} jobs: diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index f3783045820..e690896bdd2 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -10,6 +10,7 @@ concurrency: cancel-in-progress: false env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.x" PNPM_VERSION: "10.23.0" diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 8ece9010a20..5320ef7d712 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -17,6 +17,9 @@ concurrency: group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: sandbox-common-smoke: runs-on: blacksmith-16vcpu-ubuntu-2404 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e6feef90e6b..f36361e987e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -5,6 +5,9 @@ on: - cron: "17 3 * * *" workflow_dispatch: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: {} jobs: diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 19668e697ad..e6cbaa8c9e0 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -9,6 +9,9 @@ concurrency: group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: no-tabs: runs-on: blacksmith-16vcpu-ubuntu-2404 From 966653e1749d13dfe70f3579c7c0a15f60fec88c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:48:34 +0000 Subject: [PATCH 025/663] ci: suppress expected zizmor pull_request_target findings --- .github/workflows/auto-response.yml | 2 +- .github/workflows/labeler.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index c3aca216775..cc1601886a4 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -5,7 +5,7 @@ on: types: [opened, edited, labeled] issue_comment: types: [created] - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution types: [labeled] env: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 716f39ea24c..8e7d707a3d1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,7 +1,7 @@ name: Labeler on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution types: [opened, synchronize, reopened] issues: types: [opened] From ef8cc3d0fb083c965e89932ad52b2d69879a9533 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:32:26 +0000 Subject: [PATCH 026/663] refactor: share tlon inline text rendering --- extensions/tlon/src/monitor/utils.ts | 131 +++++++++++---------------- 1 file changed, 55 insertions(+), 76 deletions(-) diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index c0649dfbe85..3eccbf6cbc9 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -162,41 +162,55 @@ export function isGroupInviteAllowed( } // Helper to recursively extract text from inline content +function renderInlineItem( + item: any, + options?: { + linkMode?: "content-or-href" | "href"; + allowBreak?: boolean; + allowBlockquote?: boolean; + }, +): string { + if (typeof item === "string") { + return item; + } + if (!item || typeof item !== "object") { + return ""; + } + if (item.ship) { + return item.ship; + } + if ("sect" in item) { + return `@${item.sect || "all"}`; + } + if (options?.allowBreak && item.break !== undefined) { + return "\n"; + } + if (item["inline-code"]) { + return `\`${item["inline-code"]}\``; + } + if (item.code) { + return `\`${item.code}\``; + } + if (item.link && item.link.href) { + return options?.linkMode === "href" ? item.link.href : item.link.content || item.link.href; + } + if (item.bold && Array.isArray(item.bold)) { + return `**${extractInlineText(item.bold)}**`; + } + if (item.italics && Array.isArray(item.italics)) { + return `*${extractInlineText(item.italics)}*`; + } + if (item.strike && Array.isArray(item.strike)) { + return `~~${extractInlineText(item.strike)}~~`; + } + if (options?.allowBlockquote && item.blockquote && Array.isArray(item.blockquote)) { + return `> ${extractInlineText(item.blockquote)}`; + } + return ""; +} + function extractInlineText(items: any[]): string { - return items - .map((item: any) => { - if (typeof item === "string") { - return item; - } - if (item && typeof item === "object") { - if (item.ship) { - return item.ship; - } - if ("sect" in item) { - return `@${item.sect || "all"}`; - } - if (item["inline-code"]) { - return `\`${item["inline-code"]}\``; - } - if (item.code) { - return `\`${item.code}\``; - } - if (item.link && item.link.href) { - return item.link.content || item.link.href; - } - if (item.bold && Array.isArray(item.bold)) { - return `**${extractInlineText(item.bold)}**`; - } - if (item.italics && Array.isArray(item.italics)) { - return `*${extractInlineText(item.italics)}*`; - } - if (item.strike && Array.isArray(item.strike)) { - return `~~${extractInlineText(item.strike)}~~`; - } - } - return ""; - }) - .join(""); + return items.map((item: any) => renderInlineItem(item)).join(""); } export function extractMessageText(content: unknown): string { @@ -209,48 +223,13 @@ export function extractMessageText(content: unknown): string { // Handle inline content (text, ships, links, etc.) if (verse.inline && Array.isArray(verse.inline)) { return verse.inline - .map((item: any) => { - if (typeof item === "string") { - return item; - } - if (item && typeof item === "object") { - if (item.ship) { - return item.ship; - } - // Handle sect (role mentions like @all) - if ("sect" in item) { - return `@${item.sect || "all"}`; - } - if (item.break !== undefined) { - return "\n"; - } - if (item.link && item.link.href) { - return item.link.href; - } - // Handle inline code (Tlon uses "inline-code" key) - if (item["inline-code"]) { - return `\`${item["inline-code"]}\``; - } - if (item.code) { - return `\`${item.code}\``; - } - // Handle bold/italic/strike - recursively extract text - if (item.bold && Array.isArray(item.bold)) { - return `**${extractInlineText(item.bold)}**`; - } - if (item.italics && Array.isArray(item.italics)) { - return `*${extractInlineText(item.italics)}*`; - } - if (item.strike && Array.isArray(item.strike)) { - return `~~${extractInlineText(item.strike)}~~`; - } - // Handle blockquote inline - if (item.blockquote && Array.isArray(item.blockquote)) { - return `> ${extractInlineText(item.blockquote)}`; - } - } - return ""; - }) + .map((item: any) => + renderInlineItem(item, { + linkMode: "href", + allowBreak: true, + allowBlockquote: true, + }), + ) .join(""); } From 6b07604d64b8a59350fc420fe3152ebaa6530602 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:33:09 +0000 Subject: [PATCH 027/663] refactor: share nextcloud target normalization --- .../nextcloud-talk/src/normalize.test.ts | 28 +++++++++++++++++++ extensions/nextcloud-talk/src/normalize.ts | 9 ++++-- extensions/nextcloud-talk/src/send.ts | 18 ++---------- 3 files changed, 37 insertions(+), 18 deletions(-) create mode 100644 extensions/nextcloud-talk/src/normalize.test.ts diff --git a/extensions/nextcloud-talk/src/normalize.test.ts b/extensions/nextcloud-talk/src/normalize.test.ts new file mode 100644 index 00000000000..2419e063ff1 --- /dev/null +++ b/extensions/nextcloud-talk/src/normalize.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + looksLikeNextcloudTalkTargetId, + normalizeNextcloudTalkMessagingTarget, + stripNextcloudTalkTargetPrefix, +} from "./normalize.js"; + +describe("nextcloud-talk target normalization", () => { + it("strips supported prefixes to a room token", () => { + expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123"); + expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123"); + expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops"); + expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops"); + expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined(); + }); + + it("normalizes messaging targets to lowercase channel ids", () => { + expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123"); + expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops"); + }); + + it("detects prefixed and bare room ids", () => { + expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("")).toBe(false); + }); +}); diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts index 6854d603fc0..295caadd8a4 100644 --- a/extensions/nextcloud-talk/src/normalize.ts +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -1,4 +1,4 @@ -export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { +export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined { const trimmed = raw.trim(); if (!trimmed) { return undefined; @@ -22,7 +22,12 @@ export function normalizeNextcloudTalkMessagingTarget(raw: string): string | und return undefined; } - return `nextcloud-talk:${normalized}`.toLowerCase(); + return normalized; +} + +export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { + const normalized = stripNextcloudTalkTargetPrefix(raw); + return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined; } export function looksLikeNextcloudTalkTargetId(raw: string): boolean { diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 7cc8f05658c..4af8bde76f7 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -1,4 +1,5 @@ import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { stripNextcloudTalkTargetPrefix } from "./normalize.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { generateNextcloudTalkSignature } from "./signature.js"; import type { CoreConfig, NextcloudTalkSendResult } from "./types.js"; @@ -34,22 +35,7 @@ function resolveCredentials( } function normalizeRoomToken(to: string): string { - const trimmed = to.trim(); - if (!trimmed) { - throw new Error("Room token is required for Nextcloud Talk sends"); - } - - let normalized = trimmed; - if (normalized.startsWith("nextcloud-talk:")) { - normalized = normalized.slice("nextcloud-talk:".length).trim(); - } else if (normalized.startsWith("nc:")) { - normalized = normalized.slice("nc:".length).trim(); - } - - if (normalized.startsWith("room:")) { - normalized = normalized.slice("room:".length).trim(); - } - + const normalized = stripNextcloudTalkTargetPrefix(to); if (!normalized) { throw new Error("Room token is required for Nextcloud Talk sends"); } From a4525b721edd05680a20135fcac6e607c50966bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:33:59 +0000 Subject: [PATCH 028/663] refactor: deduplicate nextcloud send context --- extensions/nextcloud-talk/src/send.ts | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 4af8bde76f7..2b6284a6fc2 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -42,11 +42,12 @@ function normalizeRoomToken(to: string): string { return normalized; } -export async function sendMessageNextcloudTalk( - to: string, - text: string, - opts: NextcloudTalkSendOpts = {}, -): Promise { +function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): { + cfg: CoreConfig; + account: ReturnType; + baseUrl: string; + secret: string; +} { const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; const account = resolveNextcloudTalkAccount({ cfg, @@ -56,6 +57,15 @@ export async function sendMessageNextcloudTalk( { baseUrl: opts.baseUrl, secret: opts.secret }, account, ); + return { cfg, account, baseUrl, secret }; +} + +export async function sendMessageNextcloudTalk( + to: string, + text: string, + opts: NextcloudTalkSendOpts = {}, +): Promise { + const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts); const roomToken = normalizeRoomToken(to); if (!text?.trim()) { @@ -162,15 +172,7 @@ export async function sendReactionNextcloudTalk( reaction: string, opts: Omit = {}, ): Promise<{ ok: true }> { - const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig; - const account = resolveNextcloudTalkAccount({ - cfg, - accountId: opts.accountId, - }); - const { baseUrl, secret } = resolveCredentials( - { baseUrl: opts.baseUrl, secret: opts.secret }, - account, - ); + const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts); const normalizedToken = normalizeRoomToken(roomToken); const body = JSON.stringify({ reaction }); From 1ff8de3a8a7a1990c2b2ce0f11be2cfefabf9f1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:35:18 +0000 Subject: [PATCH 029/663] test: deduplicate session target discovery cases --- src/config/sessions/targets.test.ts | 305 ++++++++++------------------ 1 file changed, 104 insertions(+), 201 deletions(-) diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 8d924c8feae..720cc3e892e 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -15,6 +15,58 @@ async function resolveRealStorePath(sessionsDir: string): Promise { return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json")); } +async function createAgentSessionStores( + root: string, + agentIds: string[], +): Promise> { + const storePaths: Record = {}; + for (const agentId of agentIds) { + const sessionsDir = path.join(root, "agents", agentId, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile(path.join(sessionsDir, "sessions.json"), "{}", "utf8"); + storePaths[agentId] = await resolveRealStorePath(sessionsDir); + } + return storePaths; +} + +function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig { + return { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: defaultAgentId, default: true }], + }, + }; +} + +function expectTargetsToContainStores( + targets: Array<{ agentId: string; storePath: string }>, + stores: Record, +): void { + expect(targets).toEqual( + expect.arrayContaining( + Object.entries(stores).map(([agentId, storePath]) => ({ + agentId, + storePath, + })), + ), + ); +} + +const discoveryResolvers = [ + { + label: "async", + resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) => + await resolveAllAgentSessionStoreTargets(cfg, { env }), + }, + { + label: "sync", + resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) => + resolveAllAgentSessionStoreTargetsSync(cfg, { env }), + }, +] as const; + describe("resolveSessionStoreTargets", () => { it("resolves all configured agent stores", () => { const cfg: OpenClawConfig = { @@ -83,97 +135,39 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("includes discovered on-disk agent stores alongside configured targets", async () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); - const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + const storePaths = await createAgentSessionStores(stateDir, ["ops", "retired"]); const cfg: OpenClawConfig = { agents: { list: [{ id: "ops", default: true }], }, }; - const opsStorePath = await resolveRealStorePath(opsSessionsDir); - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).toEqual( - expect.arrayContaining([ - { - agentId: "ops", - storePath: opsStorePath, - }, - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + expectTargetsToContainStores(targets, storePaths); + expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); }); }); it("discovers retired agent stores under a configured custom session root", async () => { await withTempHome(async (home) => { const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - const opsStorePath = await resolveRealStorePath(opsSessionsDir); - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]); + const cfg = createCustomRootCfg(customRoot); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).toEqual( - expect.arrayContaining([ - { - agentId: "ops", - storePath: opsStorePath, - }, - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + expectTargetsToContainStores(targets, storePaths); + expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); }); }); it("keeps the actual on-disk store path for discovered retired agents", async () => { await withTempHome(async (home) => { const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]); + const cfg = createCustomRootCfg(customRoot); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); @@ -181,7 +175,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { expect.arrayContaining([ expect.objectContaining({ agentId: "retired-agent", - storePath: retiredStorePath, + storePath: storePaths["Retired Agent"], }), ]), ); @@ -223,73 +217,52 @@ describe("resolveAllAgentSessionStoreTargets", () => { }); }); - it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { - await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - await fs.mkdir(customRoot, { recursive: true }); - await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); + for (const resolver of discoveryResolvers) { + it(`skips unreadable or invalid discovery roots when other roots are still readable (${resolver.label})`, async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + await fs.mkdir(customRoot, { recursive: true }); + await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); - const envStateDir = path.join(home, "env-state"); - const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); - const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + const envStateDir = path.join(home, "env-state"); + const storePaths = await createAgentSessionStores(envStateDir, ["main", "retired"]); + const cfg = createCustomRootCfg(customRoot, "main"); + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "main", default: true }], - }, - }; - const env = { - ...process.env, - OPENCLAW_STATE_DIR: envStateDir, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); - - await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual( - expect.arrayContaining([ - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - }); - }); - - it("skips symlinked discovered stores under templated agents roots", async () => { - await withTempHome(async (home) => { - if (process.platform === "win32") { - return; - } - const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const leakedFile = path.join(home, "outside.json"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); - await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).not.toContainEqual({ - agentId: "ops", - storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + await expect(resolver.resolve(cfg, env)).resolves.toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: storePaths.retired, + }, + ]), + ); }); }); - }); + + it(`skips symlinked discovered stores under templated agents roots (${resolver.label})`, async () => { + await withTempHome(async (home) => { + if (process.platform === "win32") { + return; + } + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const leakedFile = path.join(home, "outside.json"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); + await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); + + const targets = await resolver.resolve(createCustomRootCfg(customRoot), process.env); + expect(targets).not.toContainEqual({ + agentId: "ops", + storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + }); + }); + }); + } it("skips discovered directories that only normalize into the default main agent", async () => { await withTempHome(async (home) => { @@ -315,73 +288,3 @@ describe("resolveAllAgentSessionStoreTargets", () => { }); }); }); - -describe("resolveAllAgentSessionStoreTargetsSync", () => { - it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { - await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - await fs.mkdir(customRoot, { recursive: true }); - await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); - - const envStateDir = path.join(home, "env-state"); - const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); - const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "main", default: true }], - }, - }; - const env = { - ...process.env, - OPENCLAW_STATE_DIR: envStateDir, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); - - expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual( - expect.arrayContaining([ - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - }); - }); - - it("skips symlinked discovered stores under templated agents roots", async () => { - await withTempHome(async (home) => { - if (process.platform === "win32") { - return; - } - const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const leakedFile = path.join(home, "outside.json"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); - await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - - const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env }); - expect(targets).not.toContainEqual({ - agentId: "ops", - storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), - }); - }); - }); -}); From 7b8e48ffb6130a93c3d97cfdb3f5f59fc3ece514 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:36:16 +0000 Subject: [PATCH 030/663] refactor: share cron manual run preflight --- src/cron/service/ops.ts | 54 ++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index c027c8d553f..de2c581bf68 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -360,13 +360,23 @@ type ManualRunDisposition = | Extract | { ok: true; runnable: true }; +type ManualRunPreflightResult = + | { ok: false } + | Extract + | { + ok: true; + runnable: true; + job: CronJob; + now: number; + }; + let nextManualRunId = 1; -async function inspectManualRunDisposition( +async function inspectManualRunPreflight( state: CronServiceState, id: string, mode?: "due" | "force", -): Promise { +): Promise { return await locked(state, async () => { warnIfDisabled(state, "run"); await ensureLoaded(state, { skipRecompute: true }); @@ -383,46 +393,50 @@ async function inspectManualRunDisposition( if (!due) { return { ok: true, ran: false, reason: "not-due" as const }; } - return { ok: true, runnable: true } as const; + return { ok: true, runnable: true, job, now } as const; }); } +async function inspectManualRunDisposition( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + const result = await inspectManualRunPreflight(state, id, mode); + if (!result.ok || !result.runnable) { + return result; + } + return { ok: true, runnable: true } as const; +} + async function prepareManualRun( state: CronServiceState, id: string, mode?: "due" | "force", ): Promise { + const preflight = await inspectManualRunPreflight(state, id, mode); + if (!preflight.ok || !preflight.runnable) { + return preflight; + } 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); + // Reserve this run under lock, then execute outside lock so read ops + // (`list`, `status`) stay responsive while the run is in progress. 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 }; - } - - // Reserve this run under lock, then execute outside lock so read ops - // (`list`, `status`) stay responsive while the run is in progress. - job.state.runningAtMs = now; + job.state.runningAtMs = preflight.now; job.state.lastError = undefined; // Persist the running marker before releasing lock so timer ticks that // force-reload from disk cannot start the same job concurrently. await persist(state); - emit(state, { jobId: job.id, action: "started", runAtMs: now }); + emit(state, { jobId: job.id, action: "started", runAtMs: preflight.now }); const executionJob = JSON.parse(JSON.stringify(job)) as CronJob; return { ok: true, ran: true, jobId: job.id, - startedAt: now, + startedAt: preflight.now, executionJob, } as const; }); From e94ac57f803c6db746f35d5356426e964da72918 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:36:39 +0000 Subject: [PATCH 031/663] refactor: reuse gateway talk provider schema fields --- src/gateway/protocol/schema/channels.ts | 33 ++++++++++--------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index ee4d6d1ea1f..041318897ac 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -16,16 +16,17 @@ export const TalkConfigParamsSchema = Type.Object( { additionalProperties: false }, ); -const TalkProviderConfigSchema = Type.Object( - { - voiceId: Type.Optional(Type.String()), - voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), - modelId: Type.Optional(Type.String()), - outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(SecretInputSchema), - }, - { additionalProperties: true }, -); +const talkProviderFieldSchemas = { + 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), +}; + +const TalkProviderConfigSchema = Type.Object(talkProviderFieldSchemas, { + additionalProperties: true, +}); const ResolvedTalkConfigSchema = Type.Object( { @@ -37,11 +38,7 @@ const ResolvedTalkConfigSchema = Type.Object( 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), + ...talkProviderFieldSchemas, interruptOnSpeech: Type.Optional(Type.Boolean()), silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, @@ -53,11 +50,7 @@ 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), + ...talkProviderFieldSchemas, interruptOnSpeech: Type.Optional(Type.Boolean()), silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, From 6b04ab1e35ed9b310b42f68dac646c17876cdb2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:37:50 +0000 Subject: [PATCH 032/663] refactor: share teams drive upload flow --- extensions/msteams/src/graph-upload.test.ts | 101 ++++++++++++++++ extensions/msteams/src/graph-upload.ts | 124 +++++++++----------- 2 files changed, 157 insertions(+), 68 deletions(-) create mode 100644 extensions/msteams/src/graph-upload.test.ts diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts new file mode 100644 index 00000000000..484075984dd --- /dev/null +++ b/extensions/msteams/src/graph-upload.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; + +describe("graph upload helpers", () => { + const tokenProvider = { + getAccessToken: vi.fn(async () => "graph-token"), + }; + + it("uploads to OneDrive with the personal drive path", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + + const result = await uploadToOneDrive({ + buffer: Buffer.from("hello"), + filename: "a.txt", + tokenProvider, + fetchFn: fetchFn as typeof fetch, + }); + + expect(fetchFn).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "Bearer graph-token", + "Content-Type": "application/octet-stream", + }), + }), + ); + expect(result).toEqual({ + id: "item-1", + webUrl: "https://example.com/1", + name: "a.txt", + }); + }); + + it("uploads to SharePoint with the site drive path", async () => { + const fetchFn = vi.fn( + async () => + new Response( + JSON.stringify({ id: "item-2", webUrl: "https://example.com/2", name: "b.txt" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + + const result = await uploadToSharePoint({ + buffer: Buffer.from("world"), + filename: "b.txt", + siteId: "site-123", + tokenProvider, + fetchFn: fetchFn as typeof fetch, + }); + + expect(fetchFn).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/b.txt:/content", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ + Authorization: "Bearer graph-token", + "Content-Type": "application/octet-stream", + }), + }), + ); + expect(result).toEqual({ + id: "item-2", + webUrl: "https://example.com/2", + name: "b.txt", + }); + }); + + it("rejects upload responses missing required fields", async () => { + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ id: "item-3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + uploadToSharePoint({ + buffer: Buffer.from("world"), + filename: "bad.txt", + siteId: "site-123", + tokenProvider, + fetchFn: fetchFn as typeof fetch, + }), + ).rejects.toThrow("SharePoint upload response missing required fields"); + }); +}); diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index 65e854ac439..9705b1a63a4 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -21,6 +21,53 @@ export interface OneDriveUploadResult { name: string; } +function parseUploadedDriveItem( + data: { id?: string; webUrl?: string; name?: string }, + label: "OneDrive" | "SharePoint", +): OneDriveUploadResult { + if (!data.id || !data.webUrl || !data.name) { + throw new Error(`${label} upload response missing required fields`); + } + + return { + id: data.id, + webUrl: data.webUrl, + name: data.name, + }; +} + +async function uploadDriveItem(params: { + buffer: Buffer; + filename: string; + contentType?: string; + tokenProvider: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; + url: string; + label: "OneDrive" | "SharePoint"; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + const res = await fetchFn(params.url, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": params.contentType ?? "application/octet-stream", + }, + body: new Uint8Array(params.buffer), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`${params.label} upload failed: ${res.status} ${res.statusText} - ${body}`); + } + + return parseUploadedDriveItem( + (await res.json()) as { id?: string; webUrl?: string; name?: string }, + params.label, + ); +} + /** * Upload a file to the user's OneDrive root folder. * For larger files, this uses the simple upload endpoint (up to 4MB). @@ -32,41 +79,13 @@ export async function uploadToOneDrive(params: { tokenProvider: MSTeamsAccessTokenProvider; fetchFn?: typeof fetch; }): Promise { - const fetchFn = params.fetchFn ?? fetch; - const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); - // Use "OpenClawShared" folder to organize bot-uploaded files const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`; - - const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": params.contentType ?? "application/octet-stream", - }, - body: new Uint8Array(params.buffer), + return await uploadDriveItem({ + ...params, + url: `${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, + label: "OneDrive", }); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`); - } - - const data = (await res.json()) as { - id?: string; - webUrl?: string; - name?: string; - }; - - if (!data.id || !data.webUrl || !data.name) { - throw new Error("OneDrive upload response missing required fields"); - } - - return { - id: data.id, - webUrl: data.webUrl, - name: data.name, - }; } export interface OneDriveSharingLink { @@ -175,44 +194,13 @@ export async function uploadToSharePoint(params: { siteId: string; fetchFn?: typeof fetch; }): Promise { - const fetchFn = params.fetchFn ?? fetch; - const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); - // Use "OpenClawShared" folder to organize bot-uploaded files const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`; - - const res = await fetchFn( - `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": params.contentType ?? "application/octet-stream", - }, - body: new Uint8Array(params.buffer), - }, - ); - - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`); - } - - const data = (await res.json()) as { - id?: string; - webUrl?: string; - name?: string; - }; - - if (!data.id || !data.webUrl || !data.name) { - throw new Error("SharePoint upload response missing required fields"); - } - - return { - id: data.id, - webUrl: data.webUrl, - name: data.name, - }; + return await uploadDriveItem({ + ...params, + url: `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, + label: "SharePoint", + }); } export interface ChatMember { From fb40b09157d718e1dd67e30ac28e027eaeda8ca0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:38:51 +0000 Subject: [PATCH 033/663] refactor: share feishu media client setup --- extensions/feishu/src/media.ts | 118 +++++++++++++++------------------ 1 file changed, 55 insertions(+), 63 deletions(-) diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 4aba038b4a9..41438c570f2 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -22,6 +22,45 @@ export type DownloadMessageResourceResult = { fileName?: string; }; +function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): { + account: ReturnType; + client: ReturnType; +} { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + + return { + account, + client: createFeishuClient({ + ...account, + httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, + }), + }; +} + +function extractFeishuUploadKey( + response: unknown, + params: { + key: "image_key" | "file_key"; + errorPrefix: string; + }, +): string { + // SDK v1.30+ returns data directly without code wrapper on success. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type + const responseAny = response as any; + if (responseAny.code !== undefined && responseAny.code !== 0) { + throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`); + } + + const key = responseAny[params.key] ?? responseAny.data?.[params.key]; + if (!key) { + throw new Error(`${params.errorPrefix}: no ${params.key} returned`); + } + return key; +} + async function readFeishuResponseBuffer(params: { response: unknown; tmpDirPrefix: string; @@ -94,15 +133,7 @@ export async function downloadImageFeishu(params: { if (!normalizedImageKey) { throw new Error("Feishu image download failed: invalid image_key"); } - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); const response = await client.im.image.get({ path: { image_key: normalizedImageKey }, @@ -132,15 +163,7 @@ export async function downloadMessageResourceFeishu(params: { if (!normalizedFileKey) { throw new Error("Feishu message resource download failed: invalid file_key"); } - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); const response = await client.im.messageResource.get({ path: { message_id: messageId, file_key: normalizedFileKey }, @@ -179,15 +202,7 @@ export async function uploadImageFeishu(params: { accountId?: string; }): Promise { const { cfg, image, imageType = "message", accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); // SDK accepts Buffer directly or fs.ReadStream for file paths // Using Readable.from(buffer) causes issues with form-data library @@ -202,20 +217,12 @@ export async function uploadImageFeishu(params: { }, }); - // SDK v1.30+ returns data directly without code wrapper on success - // On error, it throws or returns { code, msg } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type - const responseAny = response as any; - if (responseAny.code !== undefined && responseAny.code !== 0) { - throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); - } - - const imageKey = responseAny.image_key ?? responseAny.data?.image_key; - if (!imageKey) { - throw new Error("Feishu image upload failed: no image_key returned"); - } - - return { imageKey }; + return { + imageKey: extractFeishuUploadKey(response, { + key: "image_key", + errorPrefix: "Feishu image upload failed", + }), + }; } /** @@ -249,15 +256,7 @@ export async function uploadFileFeishu(params: { accountId?: string; }): Promise { const { cfg, file, fileName, fileType, duration, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient({ - ...account, - httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS, - }); + const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); // SDK accepts Buffer directly or fs.ReadStream for file paths // Using Readable.from(buffer) causes issues with form-data library @@ -276,19 +275,12 @@ export async function uploadFileFeishu(params: { }, }); - // SDK v1.30+ returns data directly without code wrapper on success - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type - const responseAny = response as any; - if (responseAny.code !== undefined && responseAny.code !== 0) { - throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`); - } - - const fileKey = responseAny.file_key ?? responseAny.data?.file_key; - if (!fileKey) { - throw new Error("Feishu file upload failed: no file_key returned"); - } - - return { fileKey }; + return { + fileKey: extractFeishuUploadKey(response, { + key: "file_key", + errorPrefix: "Feishu file upload failed", + }), + }; } /** From b6b5e5caac9d96cf8d51c1a8a3a74f02998a89b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:40:56 +0000 Subject: [PATCH 034/663] refactor: deduplicate push test fixtures --- src/gateway/server-methods/push.test.ts | 245 ++++++++++-------------- 1 file changed, 96 insertions(+), 149 deletions(-) diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 9997b336797..fc56e0e25d0 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -21,6 +21,8 @@ vi.mock("../../infra/push-apns.js", () => ({ })); import { + type ApnsPushResult, + type ApnsRegistration, clearApnsRegistrationIfCurrent, loadApnsRegistration, normalizeApnsEnvironment, @@ -32,6 +34,63 @@ import { type RespondCall = [boolean, unknown?, { code: number; message: string }?]; +const DEFAULT_DIRECT_REGISTRATION = { + nodeId: "ios-node-1", + transport: "direct", + token: "abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, +} as const; + +const DEFAULT_RELAY_REGISTRATION = { + nodeId: "ios-node-1", + transport: "relay", + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production", + distribution: "official", + updatedAtMs: 1, + tokenDebugSuffix: "abcd1234", +} as const; + +function directRegistration( + overrides: Partial> = {}, +): Extract { + return { ...DEFAULT_DIRECT_REGISTRATION, ...overrides }; +} + +function relayRegistration( + overrides: Partial> = {}, +): Extract { + return { ...DEFAULT_RELAY_REGISTRATION, ...overrides }; +} + +function mockDirectAuth() { + vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret + }, + }); +} + +function apnsResult(overrides: Partial): ApnsPushResult { + return { + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + transport: "direct", + ...overrides, + }; +} + function createInvokeParams(params: Record) { const respond = vi.fn(); return { @@ -85,31 +144,10 @@ describe("push.test handler", () => { }); it("sends push test when registration and auth are available", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); + vi.mocked(loadApnsRegistration).mockResolvedValue(directRegistration()); + mockDirectAuth(); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", - }); + vi.mocked(sendApnsAlert).mockResolvedValue(apnsResult({})); const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1", @@ -137,18 +175,9 @@ describe("push.test handler", () => { }, }, }); - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-1", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); + vi.mocked(loadApnsRegistration).mockResolvedValue( + relayRegistration({ installationId: "install-1" }), + ); vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({ ok: true, value: { @@ -157,14 +186,13 @@ describe("push.test handler", () => { }, }); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - transport: "relay", - }); + vi.mocked(sendApnsAlert).mockResolvedValue( + apnsResult({ + tokenSuffix: "abcd1234", + environment: "production", + transport: "relay", + }), + ); const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1", @@ -192,32 +220,17 @@ describe("push.test handler", () => { }); it("clears stale registrations after invalid token push-test failures", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); + const registration = directRegistration(); + vi.mocked(loadApnsRegistration).mockResolvedValue(registration); + mockDirectAuth(); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ - ok: false, - status: 400, - reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", - }); + vi.mocked(sendApnsAlert).mockResolvedValue( + apnsResult({ + ok: false, + status: 400, + reason: "BadDeviceToken", + }), + ); vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(true); const { invoke } = createInvokeParams({ @@ -229,30 +242,13 @@ describe("push.test handler", () => { expect(clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ nodeId: "ios-node-1", - registration: { - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, + registration, }); }); it("does not clear relay registrations after invalidation-shaped failures", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); + const registration = relayRegistration(); + vi.mocked(loadApnsRegistration).mockResolvedValue(registration); vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({ ok: true, value: { @@ -261,15 +257,15 @@ describe("push.test handler", () => { }, }); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); - vi.mocked(sendApnsAlert).mockResolvedValue({ + const result = apnsResult({ ok: false, status: 410, reason: "Unregistered", tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", environment: "production", transport: "relay", }); + vi.mocked(sendApnsAlert).mockResolvedValue(result); vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); const { invoke } = createInvokeParams({ @@ -280,59 +276,25 @@ describe("push.test handler", () => { await invoke(); expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ - registration: { - nodeId: "ios-node-1", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }, - result: { - ok: false, - status: 410, - reason: "Unregistered", - tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - transport: "relay", - }, + registration, + result, overrideEnvironment: null, }); expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled(); }); it("does not clear direct registrations when push.test overrides the environment", async () => { - vi.mocked(loadApnsRegistration).mockResolvedValue({ - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); + const registration = directRegistration(); + vi.mocked(loadApnsRegistration).mockResolvedValue(registration); + mockDirectAuth(); vi.mocked(normalizeApnsEnvironment).mockReturnValue("production"); - vi.mocked(sendApnsAlert).mockResolvedValue({ + const result = apnsResult({ ok: false, status: 400, reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", environment: "production", - transport: "direct", }); + vi.mocked(sendApnsAlert).mockResolvedValue(result); vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false); const { invoke } = createInvokeParams({ @@ -344,23 +306,8 @@ describe("push.test handler", () => { await invoke(); expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ - registration: { - nodeId: "ios-node-1", - transport: "direct", - token: "abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, - result: { - ok: false, - status: 400, - reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "production", - transport: "direct", - }, + registration, + result, overrideEnvironment: "production", }); expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled(); From 592dd35ce9473a6c6a127c8e2124fd7fbbcfc216 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:42:04 +0000 Subject: [PATCH 035/663] refactor: share directory config helpers --- .../plugins/directory-config-helpers.ts | 4 ++-- src/channels/plugins/directory-config.ts | 20 +------------------ 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 13cd05d65c3..72f589bc0a7 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -8,7 +8,7 @@ function resolveDirectoryLimit(limit?: number | null): number | undefined { return typeof limit === "number" && limit > 0 ? limit : undefined; } -function applyDirectoryQueryAndLimit( +export function applyDirectoryQueryAndLimit( ids: string[], params: { query?: string | null; limit?: number | null }, ): string[] { @@ -18,7 +18,7 @@ function applyDirectoryQueryAndLimit( return typeof limit === "number" ? filtered.slice(0, limit) : filtered; } -function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { +export function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { return ids.map((id) => ({ kind, id }) as const); } diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index eaf35fa33ef..e1270a9ceed 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -5,6 +5,7 @@ import { inspectSlackAccount } from "../../slack/account-inspect.js"; import { inspectTelegramAccount } from "../../telegram/account-inspect.js"; import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; +import { applyDirectoryQueryAndLimit, toDirectoryEntries } from "./directory-config-helpers.js"; import { normalizeSlackMessagingTarget } from "./normalize/slack.js"; import type { ChannelDirectoryEntry } from "./types.js"; @@ -54,25 +55,6 @@ function normalizeTrimmedSet( .filter((id): id is string => Boolean(id)); } -function resolveDirectoryQuery(query?: string | null): string { - return query?.trim().toLowerCase() || ""; -} - -function resolveDirectoryLimit(limit?: number | null): number | undefined { - return typeof limit === "number" && limit > 0 ? limit : undefined; -} - -function applyDirectoryQueryAndLimit(ids: string[], params: DirectoryConfigParams): string[] { - const q = resolveDirectoryQuery(params.query); - const limit = resolveDirectoryLimit(params.limit); - const filtered = ids.filter((id) => (q ? id.toLowerCase().includes(q) : true)); - return typeof limit === "number" ? filtered.slice(0, limit) : filtered; -} - -function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { - return ids.map((id) => ({ kind, id }) as const); -} - export async function listSlackDirectoryPeersFromConfig( params: DirectoryConfigParams, ): Promise { From 3ccf5f9dc87fbb16b4373327a70e58d4b8190b49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:43:55 +0000 Subject: [PATCH 036/663] refactor: share imessage inbound test fixtures --- .../monitor/inbound-processing.test.ts | 237 +++++------------- 1 file changed, 61 insertions(+), 176 deletions(-) diff --git a/src/imessage/monitor/inbound-processing.test.ts b/src/imessage/monitor/inbound-processing.test.ts index b18012b9f1f..d2adc37bf74 100644 --- a/src/imessage/monitor/inbound-processing.test.ts +++ b/src/imessage/monitor/inbound-processing.test.ts @@ -9,25 +9,28 @@ import { createSelfChatCache } from "./self-chat-cache.js"; describe("resolveIMessageInboundDecision echo detection", () => { const cfg = {} as OpenClawConfig; + type InboundDecisionParams = Parameters[0]; - it("drops inbound messages when outbound message id matches echo cache", () => { - const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { - return lookup.messageId === "42"; - }); - - const decision = resolveIMessageInboundDecision({ + function createInboundDecisionParams( + overrides: Omit, "message"> & { + message?: Partial; + } = {}, + ): InboundDecisionParams { + const { message: messageOverrides, ...restOverrides } = overrides; + const message = { + id: 42, + sender: "+15555550123", + text: "ok", + is_from_me: false, + is_group: false, + ...messageOverrides, + }; + const messageText = restOverrides.messageText ?? message.text ?? ""; + const bodyText = restOverrides.bodyText ?? messageText; + const baseParams: Omit = { cfg, accountId: "default", - message: { - id: 42, - sender: "+15555550123", - text: "Reasoning:\n_step_", - is_from_me: false, - is_group: false, - }, opts: undefined, - messageText: "Reasoning:\n_step_", - bodyText: "Reasoning:\n_step_", allowFrom: [], groupAllowFrom: [], groupPolicy: "open", @@ -35,8 +38,40 @@ describe("resolveIMessageInboundDecision echo detection", () => { storeAllowFrom: [], historyLimit: 0, groupHistories: new Map(), - echoCache: { has: echoHas }, + echoCache: undefined, + selfChatCache: undefined, logVerbose: undefined, + }; + return { + ...baseParams, + ...restOverrides, + message, + messageText, + bodyText, + }; + } + + function resolveDecision( + overrides: Omit, "message"> & { + message?: Partial; + } = {}, + ) { + return resolveIMessageInboundDecision(createInboundDecisionParams(overrides)); + } + + it("drops inbound messages when outbound message id matches echo cache", () => { + const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => { + return lookup.messageId === "42"; + }); + + const decision = resolveDecision({ + message: { + id: 42, + text: "Reasoning:\n_step_", + }, + messageText: "Reasoning:\n_step_", + bodyText: "Reasoning:\n_step_", + echoCache: { has: echoHas }, }); expect(decision).toEqual({ kind: "drop", reason: "echo" }); @@ -54,58 +89,29 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9641, - sender: "+15555550123", text: "Do you want to report this issue?", created_at: createdAt, is_from_me: true, - is_group: false, }, - opts: undefined, messageText: "Do you want to report this issue?", bodyText: "Do you want to report this issue?", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "from me" }); expect( - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9642, - sender: "+15555550123", text: "Do you want to report this issue?", created_at: createdAt, - is_from_me: false, - is_group: false, }, - opts: undefined, messageText: "Do you want to report this issue?", bodyText: "Do you want to report this issue?", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "self-chat echo" }); }); @@ -113,56 +119,23 @@ describe("resolveIMessageInboundDecision echo detection", () => { it("does not drop same-text messages when created_at differs", () => { const selfChatCache = createSelfChatCache(); - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9641, - sender: "+15555550123", text: "ok", created_at: "2026-03-02T20:58:10.649Z", is_from_me: true, - is_group: false, }, - opts: undefined, - messageText: "ok", - bodyText: "ok", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); - const decision = resolveIMessageInboundDecision({ - cfg, - accountId: "default", + const decision = resolveDecision({ message: { id: 9642, - sender: "+15555550123", text: "ok", created_at: "2026-03-02T20:58:11.649Z", - is_from_me: false, - is_group: false, }, - opts: undefined, - messageText: "ok", - bodyText: "ok", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); expect(decision.kind).toBe("dispatch"); @@ -183,59 +156,28 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveIMessageInboundDecision({ + resolveDecision({ cfg: groupedCfg, - accountId: "default", message: { id: 9701, chat_id: 123, - sender: "+15555550123", text: "same text", created_at: createdAt, is_from_me: true, - is_group: false, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "from me" }); - const decision = resolveIMessageInboundDecision({ + const decision = resolveDecision({ cfg: groupedCfg, - accountId: "default", message: { id: 9702, chat_id: 456, - sender: "+15555550123", text: "same text", created_at: createdAt, - is_from_me: false, - is_group: false, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); expect(decision.kind).toBe("dispatch"); @@ -246,59 +188,29 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; expect( - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9751, chat_id: 123, - sender: "+15555550123", text: "same text", created_at: createdAt, is_from_me: true, is_group: true, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }), ).toEqual({ kind: "drop", reason: "from me" }); - const decision = resolveIMessageInboundDecision({ - cfg, - accountId: "default", + const decision = resolveDecision({ message: { id: 9752, chat_id: 123, sender: "+15555550999", text: "same text", created_at: createdAt, - is_from_me: false, is_group: true, }, - opts: undefined, - messageText: "same text", - bodyText: "same text", - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, - logVerbose: undefined, }); expect(decision.kind).toBe("dispatch"); @@ -310,54 +222,27 @@ describe("resolveIMessageInboundDecision echo detection", () => { const createdAt = "2026-03-02T20:58:10.649Z"; const bodyText = "line-1\nline-2\t\u001b[31mred"; - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9801, - sender: "+15555550123", text: bodyText, created_at: createdAt, is_from_me: true, - is_group: false, }, - opts: undefined, messageText: bodyText, bodyText, - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, logVerbose, }); - resolveIMessageInboundDecision({ - cfg, - accountId: "default", + resolveDecision({ message: { id: 9802, - sender: "+15555550123", text: bodyText, created_at: createdAt, - is_from_me: false, - is_group: false, }, - opts: undefined, messageText: bodyText, bodyText, - allowFrom: [], - groupAllowFrom: [], - groupPolicy: "open", - dmPolicy: "open", - storeAllowFrom: [], - historyLimit: 0, - groupHistories: new Map(), - echoCache: undefined, selfChatCache, logVerbose, }); From e351a86290f7552a09b21a3dff3462fdd44b166f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:45:41 +0000 Subject: [PATCH 037/663] refactor: share node wake test apns fixtures --- .../server-methods/nodes.invoke-wake.test.ts | 219 ++++++++---------- 1 file changed, 97 insertions(+), 122 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 36d19a9a014..23976d71db0 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -59,6 +59,92 @@ type TestNodeSession = { }; const WAKE_WAIT_TIMEOUT_MS = 3_001; +const DEFAULT_RELAY_CONFIG = { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, +} as const; +type WakeResultOverrides = Partial<{ + ok: boolean; + status: number; + reason: string; + tokenSuffix: string; + topic: string; + environment: "sandbox" | "production"; + transport: "direct" | "relay"; +}>; + +function directRegistration(nodeId: string) { + return { + nodeId, + transport: "direct" as const, + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox" as const, + updatedAtMs: 1, + }; +} + +function relayRegistration(nodeId: string) { + return { + nodeId, + transport: "relay" as const, + relayHandle: "relay-handle-123", + sendGrant: "send-grant-123", + installationId: "install-123", + topic: "ai.openclaw.ios", + environment: "production" as const, + distribution: "official" as const, + updatedAtMs: 1, + tokenDebugSuffix: "abcd1234", + }; +} + +function mockDirectWakeConfig(nodeId: string, overrides: WakeResultOverrides = {}) { + mocks.loadApnsRegistration.mockResolvedValue(directRegistration(nodeId)); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + transport: "direct", + ...overrides, + }); +} + +function mockRelayWakeConfig(nodeId: string, overrides: WakeResultOverrides = {}) { + mocks.loadConfig.mockReturnValue({ + gateway: { + push: { + apns: { + relay: DEFAULT_RELAY_CONFIG, + }, + }, + }, + }); + mocks.loadApnsRegistration.mockResolvedValue(relayRegistration(nodeId)); + mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({ + ok: true, + value: DEFAULT_RELAY_CONFIG, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "abcd1234", + topic: "ai.openclaw.ios", + environment: "production", + transport: "relay", + ...overrides, + }); +} function makeNodeInvokeParams(overrides?: Partial>) { return { @@ -157,33 +243,6 @@ async function ackPending(nodeId: string, ids: string[]) { return respond; } -function mockSuccessfulWakeConfig(nodeId: string) { - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId, - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", - }); -} - describe("node.invoke APNs wake path", () => { beforeEach(() => { mocks.loadConfig.mockClear(); @@ -227,18 +286,7 @@ describe("node.invoke APNs wake path", () => { }); it("does not throttle repeated relay wake attempts when relay config is missing", async () => { - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-relay-no-auth", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); + mocks.loadApnsRegistration.mockResolvedValue(relayRegistration("ios-node-relay-no-auth")); mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({ ok: false, error: "relay config missing", @@ -265,7 +313,7 @@ describe("node.invoke APNs wake path", () => { it("wakes and retries invoke after the node reconnects", async () => { vi.useFakeTimers(); - mockSuccessfulWakeConfig("ios-node-reconnect"); + mockDirectWakeConfig("ios-node-reconnect"); let connected = false; const session: TestNodeSession = { nodeId: "ios-node-reconnect", commands: ["camera.capture"] }; @@ -308,30 +356,12 @@ describe("node.invoke APNs wake path", () => { }); it("clears stale registrations after an invalid device token wake failure", async () => { - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-stale", - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ + const registration = directRegistration("ios-node-stale"); + mocks.loadApnsRegistration.mockResolvedValue(registration); + mockDirectWakeConfig("ios-node-stale", { ok: false, status: 400, reason: "BadDeviceToken", - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - transport: "direct", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(true); @@ -350,57 +380,16 @@ describe("node.invoke APNs wake path", () => { expect(call?.[2]?.message).toBe("node not connected"); expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ nodeId: "ios-node-stale", - registration: { - nodeId: "ios-node-stale", - transport: "direct", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }, + registration, }); }); it("does not clear relay registrations from wake failures", async () => { - mocks.loadConfig.mockReturnValue({ - gateway: { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - }, - }, - }, - }); - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }); - mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({ - ok: true, - value: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ + const registration = relayRegistration("ios-node-relay"); + mockRelayWakeConfig("ios-node-relay", { ok: false, status: 410, reason: "Unregistered", - tokenSuffix: "abcd1234", - topic: "ai.openclaw.ios", - environment: "production", - transport: "relay", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(false); @@ -420,26 +409,12 @@ describe("node.invoke APNs wake path", () => { expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { push: { apns: { - relay: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, + relay: DEFAULT_RELAY_CONFIG, }, }, }); expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({ - registration: { - nodeId: "ios-node-relay", - transport: "relay", - relayHandle: "relay-handle-123", - sendGrant: "send-grant-123", - installationId: "install-123", - topic: "ai.openclaw.ios", - environment: "production", - distribution: "official", - updatedAtMs: 1, - tokenDebugSuffix: "abcd1234", - }, + registration, result: { ok: false, status: 410, @@ -455,7 +430,7 @@ describe("node.invoke APNs wake path", () => { it("forces one retry wake when the first wake still fails to reconnect", async () => { vi.useFakeTimers(); - mockSuccessfulWakeConfig("ios-node-throttle"); + mockDirectWakeConfig("ios-node-throttle"); const nodeRegistry = { get: vi.fn(() => undefined), From acfb95e2c65f6b1be25d70ae76e40d638fd3e4e9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:46:51 +0000 Subject: [PATCH 038/663] refactor: share tlon channel put requests --- extensions/tlon/src/urbit/channel-ops.ts | 91 ++++++++++-------------- 1 file changed, 36 insertions(+), 55 deletions(-) diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index f5401d3bb73..ef65e4ca9fe 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -12,6 +12,29 @@ export type UrbitChannelDeps = { fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; }; +async function putUrbitChannel( + deps: UrbitChannelDeps, + params: { body: unknown; auditContext: string }, +) { + return await urbitFetch({ + baseUrl: deps.baseUrl, + path: `/~/channel/${deps.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: deps.cookie, + }, + body: JSON.stringify(params.body), + }, + ssrfPolicy: deps.ssrfPolicy, + lookupFn: deps.lookupFn, + fetchImpl: deps.fetchImpl, + timeoutMs: 30_000, + auditContext: params.auditContext, + }); +} + export async function pokeUrbitChannel( deps: UrbitChannelDeps, params: { app: string; mark: string; json: unknown; auditContext: string }, @@ -26,21 +49,8 @@ export async function pokeUrbitChannel( json: params.json, }; - const { response, release } = await urbitFetch({ - baseUrl: deps.baseUrl, - path: `/~/channel/${deps.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: deps.cookie, - }, - body: JSON.stringify([pokeData]), - }, - ssrfPolicy: deps.ssrfPolicy, - lookupFn: deps.lookupFn, - fetchImpl: deps.fetchImpl, - timeoutMs: 30_000, + const { response, release } = await putUrbitChannel(deps, { + body: [pokeData], auditContext: params.auditContext, }); @@ -88,23 +98,7 @@ export async function createUrbitChannel( deps: UrbitChannelDeps, params: { body: unknown; auditContext: string }, ): Promise { - const { response, release } = await urbitFetch({ - baseUrl: deps.baseUrl, - path: `/~/channel/${deps.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: deps.cookie, - }, - body: JSON.stringify(params.body), - }, - ssrfPolicy: deps.ssrfPolicy, - lookupFn: deps.lookupFn, - fetchImpl: deps.fetchImpl, - timeoutMs: 30_000, - auditContext: params.auditContext, - }); + const { response, release } = await putUrbitChannel(deps, params); try { if (!response.ok && response.status !== 204) { @@ -116,30 +110,17 @@ export async function createUrbitChannel( } export async function wakeUrbitChannel(deps: UrbitChannelDeps): Promise { - const { response, release } = await urbitFetch({ - baseUrl: deps.baseUrl, - path: `/~/channel/${deps.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: deps.cookie, + const { response, release } = await putUrbitChannel(deps, { + body: [ + { + id: Date.now(), + action: "poke", + ship: deps.ship, + app: "hood", + mark: "helm-hi", + json: "Opening API channel", }, - body: JSON.stringify([ - { - id: Date.now(), - action: "poke", - ship: deps.ship, - app: "hood", - mark: "helm-hi", - json: "Opening API channel", - }, - ]), - }, - ssrfPolicy: deps.ssrfPolicy, - lookupFn: deps.lookupFn, - fetchImpl: deps.fetchImpl, - timeoutMs: 30_000, + ], auditContext: "tlon-urbit-channel-wake", }); From 49f3fbf726c09e3aaab0f36db9ac690e50dadc2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:49:50 +0000 Subject: [PATCH 039/663] fix: restore cron manual run type narrowing --- src/cron/service/ops.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index de2c581bf68..69751e4dfdb 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -403,7 +403,10 @@ async function inspectManualRunDisposition( mode?: "due" | "force", ): Promise { const result = await inspectManualRunPreflight(state, id, mode); - if (!result.ok || !result.runnable) { + if (!result.ok) { + return result; + } + if ("reason" in result) { return result; } return { ok: true, runnable: true } as const; @@ -415,9 +418,16 @@ async function prepareManualRun( mode?: "due" | "force", ): Promise { const preflight = await inspectManualRunPreflight(state, id, mode); - if (!preflight.ok || !preflight.runnable) { + if (!preflight.ok) { return preflight; } + if ("reason" in preflight) { + return { + ok: true, + ran: false, + reason: preflight.reason, + } as const; + } return await locked(state, async () => { // Reserve this run under lock, then execute outside lock so read ops // (`list`, `status`) stay responsive while the run is in progress. From a14a32695d51da53ff3e4421ec5a363a11cd6939 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:49:56 +0000 Subject: [PATCH 040/663] refactor: share feishu reaction client setup --- extensions/feishu/src/reactions.ts | 47 +++++++++++++----------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/extensions/feishu/src/reactions.ts b/extensions/feishu/src/reactions.ts index d446a674b88..951b3d03c6b 100644 --- a/extensions/feishu/src/reactions.ts +++ b/extensions/feishu/src/reactions.ts @@ -9,6 +9,20 @@ export type FeishuReaction = { operatorId: string; }; +function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) { + const account = resolveFeishuAccount(params); + if (!account.configured) { + throw new Error(`Feishu account "${account.accountId}" not configured`); + } + return createFeishuClient(account); +} + +function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) { + if (response.code !== 0) { + throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`); + } +} + /** * Add a reaction (emoji) to a message. * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART" @@ -21,12 +35,7 @@ export async function addReactionFeishu(params: { accountId?: string; }): Promise<{ reactionId: string }> { const { cfg, messageId, emojiType, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); + const client = resolveConfiguredFeishuClient({ cfg, accountId }); const response = (await client.im.messageReaction.create({ path: { message_id: messageId }, @@ -41,9 +50,7 @@ export async function addReactionFeishu(params: { data?: { reaction_id?: string }; }; - if (response.code !== 0) { - throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`); - } + assertFeishuReactionApiSuccess(response, "add reaction"); const reactionId = response.data?.reaction_id; if (!reactionId) { @@ -63,12 +70,7 @@ export async function removeReactionFeishu(params: { accountId?: string; }): Promise { const { cfg, messageId, reactionId, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); + const client = resolveConfiguredFeishuClient({ cfg, accountId }); const response = (await client.im.messageReaction.delete({ path: { @@ -77,9 +79,7 @@ export async function removeReactionFeishu(params: { }, })) as { code?: number; msg?: string }; - if (response.code !== 0) { - throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`); - } + assertFeishuReactionApiSuccess(response, "remove reaction"); } /** @@ -92,12 +92,7 @@ export async function listReactionsFeishu(params: { accountId?: string; }): Promise { const { cfg, messageId, emojiType, accountId } = params; - const account = resolveFeishuAccount({ cfg, accountId }); - if (!account.configured) { - throw new Error(`Feishu account "${account.accountId}" not configured`); - } - - const client = createFeishuClient(account); + const client = resolveConfiguredFeishuClient({ cfg, accountId }); const response = (await client.im.messageReaction.list({ path: { message_id: messageId }, @@ -115,9 +110,7 @@ export async function listReactionsFeishu(params: { }; }; - if (response.code !== 0) { - throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`); - } + assertFeishuReactionApiSuccess(response, "list reactions"); const items = response.data?.items ?? []; return items.map((item) => ({ From e358d57fb5141c9dae8c0dbd8010baf0f03eebdc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:50:43 +0000 Subject: [PATCH 041/663] refactor: share feishu reply fallback flow --- extensions/feishu/src/send.ts | 118 +++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 52 deletions(-) diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 0f4fd7e7758..5bfa836e0a6 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -43,6 +43,10 @@ function isWithdrawnReplyError(err: unknown): boolean { type FeishuCreateMessageClient = { im: { message: { + reply: (opts: { + path: { message_id: string }; + data: { content: string; msg_type: string; reply_in_thread?: true }; + }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>; create: (opts: { params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" }; data: { receive_id: string; content: string; msg_type: string }; @@ -74,6 +78,50 @@ async function sendFallbackDirect( return toFeishuSendResult(response, params.receiveId); } +async function sendReplyOrFallbackDirect( + client: FeishuCreateMessageClient, + params: { + replyToMessageId?: string; + replyInThread?: boolean; + content: string; + msgType: string; + directParams: { + receiveId: string; + receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id"; + content: string; + msgType: string; + }; + directErrorPrefix: string; + replyErrorPrefix: string; + }, +): Promise { + if (!params.replyToMessageId) { + return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + } + + let response: { code?: number; msg?: string; data?: { message_id?: string } }; + try { + response = await client.im.message.reply({ + path: { message_id: params.replyToMessageId }, + data: { + content: params.content, + msg_type: params.msgType, + ...(params.replyInThread ? { reply_in_thread: true } : {}), + }, + }); + } catch (err) { + if (!isWithdrawnReplyError(err)) { + throw err; + } + return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + } + if (shouldFallbackFromReplyTarget(response)) { + return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); + } + assertFeishuMessageApiSuccess(response, params.replyErrorPrefix); + return toFeishuSendResult(response, params.directParams.receiveId); +} + function parseInteractiveCardContent(parsed: unknown): string { if (!parsed || typeof parsed !== "object") { return "[Interactive Card]"; @@ -290,32 +338,15 @@ export async function sendMessageFeishu( const { content, msgType } = buildFeishuPostMessagePayload({ messageText }); const directParams = { receiveId, receiveIdType, content, msgType }; - - if (replyToMessageId) { - let response: { code?: number; msg?: string; data?: { message_id?: string } }; - try { - response = await client.im.message.reply({ - path: { message_id: replyToMessageId }, - data: { - content, - msg_type: msgType, - ...(replyInThread ? { reply_in_thread: true } : {}), - }, - }); - } catch (err) { - if (!isWithdrawnReplyError(err)) { - throw err; - } - return sendFallbackDirect(client, directParams, "Feishu send failed"); - } - if (shouldFallbackFromReplyTarget(response)) { - return sendFallbackDirect(client, directParams, "Feishu send failed"); - } - assertFeishuMessageApiSuccess(response, "Feishu reply failed"); - return toFeishuSendResult(response, receiveId); - } - - return sendFallbackDirect(client, directParams, "Feishu send failed"); + return sendReplyOrFallbackDirect(client, { + replyToMessageId, + replyInThread, + content, + msgType, + directParams, + directErrorPrefix: "Feishu send failed", + replyErrorPrefix: "Feishu reply failed", + }); } export type SendFeishuCardParams = { @@ -334,32 +365,15 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise Date: Fri, 13 Mar 2026 16:57:20 +0000 Subject: [PATCH 042/663] ci: modernize GitHub Actions workflow versions --- .github/actions/setup-node-env/action.yml | 4 +- .../actions/setup-pnpm-store-cache/action.yml | 4 +- .github/workflows/auto-response.yml | 6 +- .github/workflows/ci.yml | 56 +++++++++---------- .github/workflows/codeql.yml | 8 +-- .github/workflows/docker-release.yml | 16 +++--- .github/workflows/install-smoke.yml | 6 +- .github/workflows/labeler.yml | 24 ++++---- .github/workflows/openclaw-npm-release.yml | 2 +- .github/workflows/sandbox-common-smoke.yml | 4 +- .github/workflows/stale.yml | 14 ++--- .github/workflows/workflow-sanity.yml | 4 +- 12 files changed, 74 insertions(+), 74 deletions(-) diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 5ea0373ff76..41ca9eb98b0 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -49,7 +49,7 @@ runs: exit 1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} check-latest: false @@ -63,7 +63,7 @@ runs: - name: Setup Bun if: inputs.install-bun == 'true' - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@v2.1.3 with: bun-version: "1.3.9" diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml index 249544d49ac..2f7c992a978 100644 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ b/.github/actions/setup-pnpm-store-cache/action.yml @@ -61,14 +61,14 @@ runs: - name: Restore pnpm store cache (exact key only) # PRs that request sticky disks still need a safe cache restore path. if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }} - name: Restore pnpm store cache (with fallback keys) if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true' - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index cc1601886a4..69dff002c7b 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -20,20 +20,20 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Handle labeled items - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18c6f14fdaf..b365b2ed944 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: docs_changed: ${{ steps.check.outputs.docs_changed }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: false @@ -53,7 +53,7 @@ jobs: run_windows: ${{ steps.scope.outputs.run_windows }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: false @@ -86,7 +86,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -101,13 +101,13 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Build dist run: pnpm build - name: Upload dist artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist-build path: dist/ @@ -120,7 +120,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -128,10 +128,10 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Download dist artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: dist-build path: dist/ @@ -166,7 +166,7 @@ jobs: - name: Checkout if: github.event_name != 'push' || matrix.runtime != 'bun' - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -175,7 +175,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "${{ matrix.runtime == 'bun' }}" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Configure Node test resources if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' @@ -197,7 +197,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -205,7 +205,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Check types and lint and oxfmt run: pnpm check @@ -223,7 +223,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -231,7 +231,7 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Check docs run: pnpm check:docs @@ -243,7 +243,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -253,7 +253,7 @@ jobs: node-version: "22.x" cache-key-suffix: "node22" install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Configure Node 22 test resources run: | @@ -276,12 +276,12 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -300,7 +300,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -319,7 +319,7 @@ jobs: - name: Setup Python id: setup-python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" cache: "pip" @@ -329,7 +329,7 @@ jobs: .github/workflows/ci.yml - name: Restore pre-commit cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pre-commit key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} @@ -412,7 +412,7 @@ jobs: command: pnpm test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -436,7 +436,7 @@ jobs: } - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v6 with: node-version: 24.x check-latest: false @@ -498,7 +498,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -534,7 +534,7 @@ jobs: swiftformat --lint apps/macos/Sources --config .swiftformat - name: Cache SwiftPM - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/Library/Caches/org.swift.swiftpm key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }} @@ -570,7 +570,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -739,12 +739,12 @@ jobs: command: ./gradlew --no-daemon :app:assembleDebug steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin # setup-android's sdkmanager currently crashes on JDK 21 in CI. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e01f7185a37..79c041ef727 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -70,7 +70,7 @@ jobs: config_file: "" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -79,17 +79,17 @@ jobs: uses: ./.github/actions/setup-node-env with: install-bun: "false" - use-sticky-disk: "true" + use-sticky-disk: "false" - name: Setup Python if: matrix.needs_python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - name: Setup Java if: matrix.needs_java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "21" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 0486bc76760..f4128cddc88 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -34,13 +34,13 @@ jobs: slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -135,13 +135,13 @@ jobs: slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -234,10 +234,10 @@ jobs: needs: [build-amd64, build-arm64] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 26b5de0e2b6..f48c794b668 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -20,7 +20,7 @@ jobs: docs_only: ${{ steps.check.outputs.docs_only }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 fetch-tags: false @@ -41,10 +41,10 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout CLI - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # Blacksmith can fall back to the local docker driver, which rejects gha # cache export/import. Keep smoke builds driver-agnostic. diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8e7d707a3d1..3a38e5213c3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -28,25 +28,25 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + - uses: actions/labeler@v6 with: configuration-path: .github/labeler.yml repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} sync-labels: true - name: Apply PR size label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -135,7 +135,7 @@ jobs: labels: [targetSizeLabel], }); - name: Apply maintainer or trusted-contributor label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -206,7 +206,7 @@ jobs: // }); // } - name: Apply too-many-prs label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -384,20 +384,20 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Backfill PR labels - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | @@ -632,20 +632,20 @@ jobs: issues: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback if: steps.app-token.outcome == 'failure' with: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Apply maintainer or trusted-contributor label - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} script: | diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index e690896bdd2..ac0a8f728e3 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -23,7 +23,7 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 5320ef7d712..4a839b4d878 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -25,12 +25,12 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false - name: Set up Docker Builder - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build minimal sandbox base (USER sandbox) shell: bash diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index f36361e987e..95dc406da45 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,13 +17,13 @@ jobs: pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token continue-on-error: true with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token-fallback continue-on-error: true with: @@ -32,7 +32,7 @@ jobs: - name: Mark stale issues and pull requests (primary) id: stale-primary continue-on-error: true - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} days-before-issue-stale: 7 @@ -65,7 +65,7 @@ jobs: - name: Check stale state cache id: stale-state if: always() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }} script: | @@ -88,7 +88,7 @@ jobs: } - name: Mark stale issues and pull requests (fallback) if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != '' - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ steps.app-token-fallback.outputs.token }} days-before-issue-stale: 7 @@ -124,13 +124,13 @@ jobs: issues: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + - uses: actions/create-github-app-token@v2 id: app-token with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Lock closed issues after 48h of no comments - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@v8 with: github-token: ${{ steps.app-token.outputs.token }} script: | diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index e6cbaa8c9e0..9426f678926 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -17,7 +17,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Fail on tabs in workflow files run: | @@ -48,7 +48,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install actionlint shell: bash From 369430f9ab98af384f1e2342529eb88bf9acfdc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:53:14 +0000 Subject: [PATCH 043/663] refactor: share tlon upload test mocks --- extensions/tlon/src/urbit/upload.test.ts | 113 ++++++++++------------- 1 file changed, 51 insertions(+), 62 deletions(-) diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index ca95a0412d4..1a573a6b359 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -15,6 +15,36 @@ vi.mock("@tloncorp/api", () => ({ })); describe("uploadImageFromUrl", () => { + async function loadUploadMocks() { + const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); + const { uploadFile } = await import("@tloncorp/api"); + const { uploadImageFromUrl } = await import("./upload.js"); + return { + mockFetch: vi.mocked(fetchWithSsrFGuard), + mockUploadFile: vi.mocked(uploadFile), + uploadImageFromUrl, + }; + } + + type UploadMocks = Awaited>; + + function mockSuccessfulFetch(params: { + mockFetch: UploadMocks["mockFetch"]; + blob: Blob; + finalUrl: string; + contentType: string; + }) { + params.mockFetch.mockResolvedValue({ + response: { + ok: true, + headers: new Headers({ "content-type": params.contentType }), + blob: () => Promise.resolve(params.blob), + } as unknown as Response, + finalUrl: params.finalUrl, + release: vi.fn().mockResolvedValue(undefined), + }); + } + beforeEach(() => { vi.clearAllMocks(); }); @@ -24,28 +54,17 @@ describe("uploadImageFromUrl", () => { }); it("fetches image and calls uploadFile, returns uploaded URL", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); - - // Mock fetchWithSsrFGuard to return a successful response with a blob const mockBlob = new Blob(["fake-image"], { type: "image/png" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/png" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/image.png", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/png", }); - - // Mock uploadFile to return a successful upload mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" }); - const { uploadImageFromUrl } = await import("./upload.js"); const result = await uploadImageFromUrl("https://example.com/image.png"); expect(result).toBe("https://memex.tlon.network/uploaded.png"); @@ -59,10 +78,8 @@ describe("uploadImageFromUrl", () => { }); it("returns original URL if fetch fails", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); + const { mockFetch, uploadImageFromUrl } = await loadUploadMocks(); - // Mock fetchWithSsrFGuard to return a failed response mockFetch.mockResolvedValue({ response: { ok: false, @@ -72,35 +89,23 @@ describe("uploadImageFromUrl", () => { release: vi.fn().mockResolvedValue(undefined), }); - const { uploadImageFromUrl } = await import("./upload.js"); const result = await uploadImageFromUrl("https://example.com/image.png"); expect(result).toBe("https://example.com/image.png"); }); it("returns original URL if upload fails", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); - - // Mock fetchWithSsrFGuard to return a successful response const mockBlob = new Blob(["fake-image"], { type: "image/png" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/png" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/image.png", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/png", }); - - // Mock uploadFile to throw an error mockUploadFile.mockRejectedValue(new Error("Upload failed")); - const { uploadImageFromUrl } = await import("./upload.js"); const result = await uploadImageFromUrl("https://example.com/image.png"); expect(result).toBe("https://example.com/image.png"); @@ -127,26 +132,18 @@ describe("uploadImageFromUrl", () => { }); it("extracts filename from URL path", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); - - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/jpeg" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/path/to/my-image.jpg", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/jpeg", }); mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" }); - const { uploadImageFromUrl } = await import("./upload.js"); await uploadImageFromUrl("https://example.com/path/to/my-image.jpg"); expect(mockUploadFile).toHaveBeenCalledWith( @@ -157,26 +154,18 @@ describe("uploadImageFromUrl", () => { }); it("uses default filename when URL has no path", async () => { - const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon"); - const mockFetch = vi.mocked(fetchWithSsrFGuard); - - const { uploadFile } = await import("@tloncorp/api"); - const mockUploadFile = vi.mocked(uploadFile); + const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks(); const mockBlob = new Blob(["fake-image"], { type: "image/png" }); - mockFetch.mockResolvedValue({ - response: { - ok: true, - headers: new Headers({ "content-type": "image/png" }), - blob: () => Promise.resolve(mockBlob), - } as unknown as Response, + mockSuccessfulFetch({ + mockFetch, + blob: mockBlob, finalUrl: "https://example.com/", - release: vi.fn().mockResolvedValue(undefined), + contentType: "image/png", }); mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" }); - const { uploadImageFromUrl } = await import("./upload.js"); await uploadImageFromUrl("https://example.com/"); expect(mockUploadFile).toHaveBeenCalledWith( From 4a00cefe63cbe697704819379fc0bacd44d45783 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:53:31 +0000 Subject: [PATCH 044/663] refactor: share outbound plugin test results --- .../outbound/outbound-send-service.test.ts | 42 +++++++------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index ae12622fcae..68c956d93fc 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -34,6 +34,18 @@ vi.mock("../../config/sessions.js", () => ({ import { executePollAction, executeSendAction } from "./outbound-send-service.js"; describe("executeSendAction", () => { + function pluginActionResult(messageId: string) { + return { + ok: true, + value: { messageId }, + continuePrompt: "", + output: "", + sessionId: "s1", + model: "gpt-5.2", + usage: {}, + }; + } + beforeEach(() => { mocks.dispatchChannelMessageAction.mockClear(); mocks.sendMessage.mockClear(); @@ -75,15 +87,7 @@ describe("executeSendAction", () => { }); it("uses plugin poll action when available", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue({ - ok: true, - value: { messageId: "poll-plugin" }, - continuePrompt: "", - output: "", - sessionId: "s1", - model: "gpt-5.2", - usage: {}, - }); + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin")); const result = await executePollAction({ ctx: { @@ -103,15 +107,7 @@ describe("executeSendAction", () => { }); it("passes agent-scoped media local roots to plugin dispatch", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue({ - ok: true, - value: { messageId: "msg-plugin" }, - continuePrompt: "", - output: "", - sessionId: "s1", - model: "gpt-5.2", - usage: {}, - }); + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); await executeSendAction({ ctx: { @@ -134,15 +130,7 @@ describe("executeSendAction", () => { }); it("passes mirror idempotency keys through plugin-handled sends", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue({ - ok: true, - value: { messageId: "msg-plugin" }, - continuePrompt: "", - output: "", - sessionId: "s1", - model: "gpt-5.2", - usage: {}, - }); + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); await executeSendAction({ ctx: { From 8de94abfbc9b48d1ac8aae722cef074e5c8be295 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:55:23 +0000 Subject: [PATCH 045/663] refactor: share chat abort test helpers --- .../chat.abort-authorization.test.ts | 96 ++++++------------ .../chat.abort-persistence.test.ts | 97 +++++++------------ .../server-methods/chat.abort.test-helpers.ts | 69 +++++++++++++ 3 files changed, 132 insertions(+), 130 deletions(-) create mode 100644 src/gateway/server-methods/chat.abort.test-helpers.ts diff --git a/src/gateway/server-methods/chat.abort-authorization.test.ts b/src/gateway/server-methods/chat.abort-authorization.test.ts index 6fbf0478df3..607e80b58ff 100644 --- a/src/gateway/server-methods/chat.abort-authorization.test.ts +++ b/src/gateway/server-methods/chat.abort-authorization.test.ts @@ -1,68 +1,24 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { + createActiveRun, + createChatAbortContext, + invokeChatAbortHandler, +} from "./chat.abort.test-helpers.js"; import { chatHandlers } from "./chat.js"; -function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) { - const now = Date.now(); - return { - controller: new AbortController(), - sessionId: `${sessionKey}-session`, - sessionKey, - startedAtMs: now, - expiresAtMs: now + 30_000, - ownerConnId: owner?.connId, - ownerDeviceId: owner?.deviceId, - }; -} - -function createContext(overrides: Record = {}) { - return { - chatAbortControllers: new Map(), - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - chatAbortedRuns: new Map(), - removeChatRun: vi - .fn() - .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), - agentRunSeq: new Map(), - broadcast: vi.fn(), - nodeSendToSession: vi.fn(), - logGateway: { warn: vi.fn() }, - ...overrides, - }; -} - -async function invokeChatAbort(params: { - context: ReturnType; - request: { sessionKey: string; runId?: string }; - client?: { - connId?: string; - connect?: { - device?: { id?: string }; - scopes?: string[]; - }; - } | null; -}) { - const respond = vi.fn(); - await chatHandlers["chat.abort"]({ - params: params.request, - respond: respond as never, - context: params.context as never, - req: {} as never, - client: (params.client ?? null) as never, - isWebchatConnect: () => false, - }); - return respond; -} - describe("chat.abort authorization", () => { it("rejects explicit run aborts from other clients", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })], + [ + "run-1", + createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }), + ], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main", runId: "run-1" }, client: { @@ -79,13 +35,14 @@ describe("chat.abort authorization", () => { }); it("allows the same paired device to abort after reconnecting", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })], + ["run-1", createActiveRun("main", { owner: { connId: "conn-old", deviceId: "dev-1" } })], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main", runId: "run-1" }, client: { @@ -101,14 +58,15 @@ describe("chat.abort authorization", () => { }); it("only aborts session-scoped runs owned by the requester", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-mine", createActiveRun("main", { deviceId: "dev-1" })], - ["run-other", createActiveRun("main", { deviceId: "dev-2" })], + ["run-mine", createActiveRun("main", { owner: { deviceId: "dev-1" } })], + ["run-other", createActiveRun("main", { owner: { deviceId: "dev-2" } })], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main" }, client: { @@ -125,13 +83,17 @@ describe("chat.abort authorization", () => { }); it("allows operator.admin clients to bypass owner checks", async () => { - const context = createContext({ + const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })], + [ + "run-1", + createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }), + ], ]), }); - const respond = await invokeChatAbort({ + const respond = await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], context, request: { sessionKey: "main", runId: "run-1" }, client: { diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index b7add3740eb..31a00a3f186 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -3,6 +3,11 @@ import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createActiveRun, + createChatAbortContext, + invokeChatAbortHandler, +} from "./chat.abort.test-helpers.js"; type TranscriptLine = { message?: Record; @@ -31,17 +36,6 @@ vi.mock("../session-utils.js", async (importOriginal) => { const { chatHandlers } = await import("./chat.js"); -function createActiveRun(sessionKey: string, sessionId: string) { - const now = Date.now(); - return { - controller: new AbortController(), - sessionId, - sessionKey, - startedAtMs: now, - expiresAtMs: now + 30_000, - }; -} - async function writeTranscriptHeader(transcriptPath: string, sessionId: string) { const header = { type: "session", @@ -81,49 +75,6 @@ async function createTranscriptFixture(prefix: string) { return { transcriptPath, sessionId }; } -function createChatAbortContext(overrides: Record = {}): { - chatAbortControllers: Map>; - chatRunBuffers: Map; - chatDeltaSentAt: Map; - chatAbortedRuns: Map; - removeChatRun: ReturnType; - agentRunSeq: Map; - broadcast: ReturnType; - nodeSendToSession: ReturnType; - logGateway: { warn: ReturnType }; - dedupe?: { get: ReturnType }; -} { - return { - chatAbortControllers: new Map(), - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - chatAbortedRuns: new Map(), - removeChatRun: vi - .fn() - .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), - agentRunSeq: new Map(), - broadcast: vi.fn(), - nodeSendToSession: vi.fn(), - logGateway: { warn: vi.fn() }, - ...overrides, - }; -} - -async function invokeChatAbort( - context: ReturnType, - params: { sessionKey: string; runId?: string }, - respond: ReturnType, -) { - await chatHandlers["chat.abort"]({ - params, - respond: respond as never, - context: context as never, - req: {} as never, - client: null, - isWebchatConnect: () => false, - }); -} - afterEach(() => { vi.restoreAllMocks(); }); @@ -134,7 +85,7 @@ describe("chat abort transcript persistence", () => { const runId = "idem-abort-run-1"; const respond = vi.fn(); const context = createChatAbortContext({ - chatAbortControllers: new Map([[runId, createActiveRun("main", sessionId)]]), + chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]), chatRunBuffers: new Map([[runId, "Partial from run abort"]]), chatDeltaSentAt: new Map([[runId, Date.now()]]), removeChatRun: vi @@ -149,17 +100,27 @@ describe("chat abort transcript persistence", () => { logGateway: { warn: vi.fn() }, }); - await invokeChatAbort(context, { sessionKey: "main", runId }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main", runId }, + respond, + }); const [ok1, payload1] = respond.mock.calls.at(-1) ?? []; expect(ok1).toBe(true); expect(payload1).toMatchObject({ aborted: true, runIds: [runId] }); - context.chatAbortControllers.set(runId, createActiveRun("main", sessionId)); + context.chatAbortControllers.set(runId, createActiveRun("main", { sessionId })); context.chatRunBuffers.set(runId, "Partial from run abort"); context.chatDeltaSentAt.set(runId, Date.now()); - await invokeChatAbort(context, { sessionKey: "main", runId }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main", runId }, + respond, + }); const lines = await readTranscriptLines(transcriptPath); const persisted = lines @@ -188,8 +149,8 @@ describe("chat abort transcript persistence", () => { const respond = vi.fn(); const context = createChatAbortContext({ chatAbortControllers: new Map([ - ["run-a", createActiveRun("main", sessionId)], - ["run-b", createActiveRun("main", sessionId)], + ["run-a", createActiveRun("main", { sessionId })], + ["run-b", createActiveRun("main", { sessionId })], ]), chatRunBuffers: new Map([ ["run-a", "Session abort partial"], @@ -201,7 +162,12 @@ describe("chat abort transcript persistence", () => { ]), }); - await invokeChatAbort(context, { sessionKey: "main" }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main" }, + respond, + }); const [ok, payload] = respond.mock.calls.at(-1) ?? []; expect(ok).toBe(true); @@ -280,12 +246,17 @@ describe("chat abort transcript persistence", () => { const runId = "idem-abort-run-blank"; const respond = vi.fn(); const context = createChatAbortContext({ - chatAbortControllers: new Map([[runId, createActiveRun("main", sessionId)]]), + chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]), chatRunBuffers: new Map([[runId, " \n\t "]]), chatDeltaSentAt: new Map([[runId, Date.now()]]), }); - await invokeChatAbort(context, { sessionKey: "main", runId }, respond); + await invokeChatAbortHandler({ + handler: chatHandlers["chat.abort"], + context, + request: { sessionKey: "main", runId }, + respond, + }); const [ok, payload] = respond.mock.calls.at(-1) ?? []; expect(ok).toBe(true); diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts new file mode 100644 index 00000000000..fe5cd324ccb --- /dev/null +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -0,0 +1,69 @@ +import { vi } from "vitest"; + +export function createActiveRun( + sessionKey: string, + params: { + sessionId?: string; + owner?: { connId?: string; deviceId?: string }; + } = {}, +) { + const now = Date.now(); + return { + controller: new AbortController(), + sessionId: params.sessionId ?? `${sessionKey}-session`, + sessionKey, + startedAtMs: now, + expiresAtMs: now + 30_000, + ownerConnId: params.owner?.connId, + ownerDeviceId: params.owner?.deviceId, + }; +} + +export function createChatAbortContext(overrides: Record = {}) { + return { + chatAbortControllers: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatAbortedRuns: new Map(), + removeChatRun: vi + .fn() + .mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })), + agentRunSeq: new Map(), + broadcast: vi.fn(), + nodeSendToSession: vi.fn(), + logGateway: { warn: vi.fn() }, + ...overrides, + }; +} + +export async function invokeChatAbortHandler(params: { + handler: (args: { + params: { sessionKey: string; runId?: string }; + respond: never; + context: never; + req: never; + client: never; + isWebchatConnect: () => boolean; + }) => Promise; + context: ReturnType; + request: { sessionKey: string; runId?: string }; + client?: { + connId?: string; + connect?: { + device?: { id?: string }; + scopes?: string[]; + }; + } | null; + respond?: ReturnType; +}) { + const respond = params.respond ?? vi.fn(); + await params.handler({ + params: params.request, + respond: respond as never, + context: params.context as never, + req: {} as never, + client: (params.client ?? null) as never, + isWebchatConnect: () => false, + }); + return respond; +} From 644fb76960ccc63af925ebe3c460489dbec96207 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 16:56:38 +0000 Subject: [PATCH 046/663] refactor: share node pending test client --- .../server-methods/nodes.invoke-wake.test.ts | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 23976d71db0..58596d582f8 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -195,24 +195,28 @@ async function invokeNode(params: { return respond; } +function createNodeClient(nodeId: string) { + return { + connect: { + role: "node" as const, + client: { + id: nodeId, + mode: "node" as const, + name: "ios-test", + platform: "iOS 26.4.0", + version: "test", + }, + }, + }; +} + 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, + client: createNodeClient(nodeId) as never, req: { type: "req", id: "req-node-pending", method: "node.pending.pull" }, isWebchatConnect: () => false, }); @@ -225,18 +229,7 @@ async function ackPending(nodeId: string, ids: string[]) { 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, + client: createNodeClient(nodeId) as never, req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" }, isWebchatConnect: () => false, }); From ee1d4eb29dc1bb762222a9ebd937472eb10eabf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:33:03 +0000 Subject: [PATCH 047/663] test: align chat abort helpers with gateway handler types --- .../server-methods/chat.abort-persistence.test.ts | 2 +- src/gateway/server-methods/chat.abort.test-helpers.ts | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 31a00a3f186..e11b2dc08cb 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -197,7 +197,7 @@ describe("chat abort transcript persistence", () => { const { transcriptPath, sessionId } = await createTranscriptFixture("openclaw-chat-stop-"); const respond = vi.fn(); const context = createChatAbortContext({ - chatAbortControllers: new Map([["run-stop-1", createActiveRun("main", sessionId)]]), + chatAbortControllers: new Map([["run-stop-1", createActiveRun("main", { sessionId })]]), chatRunBuffers: new Map([["run-stop-1", "Partial from /stop"]]), chatDeltaSentAt: new Map([["run-stop-1", Date.now()]]), removeChatRun: vi.fn().mockReturnValue({ sessionKey: "main", clientRunId: "client-stop-1" }), diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index fe5cd324ccb..c1db68f5774 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -1,4 +1,5 @@ import { vi } from "vitest"; +import type { GatewayRequestHandler } from "./types.js"; export function createActiveRun( sessionKey: string, @@ -37,14 +38,7 @@ export function createChatAbortContext(overrides: Record = {}) } export async function invokeChatAbortHandler(params: { - handler: (args: { - params: { sessionKey: string; runId?: string }; - respond: never; - context: never; - req: never; - client: never; - isWebchatConnect: () => boolean; - }) => Promise; + handler: GatewayRequestHandler; context: ReturnType; request: { sessionKey: string; runId?: string }; client?: { From 7778627b71d442485afff9ea3496d94292eadf8f Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Sat, 14 Mar 2026 01:38:06 +0800 Subject: [PATCH 048/663] fix(ollama): hide native reasoning-only output (#45330) Thanks @xi7ang Co-authored-by: xi7ang <266449609+xi7ang@users.noreply.github.com> Co-authored-by: Frank Yang --- CHANGELOG.md | 1 + src/agents/ollama-stream.test.ts | 16 ++++++++-------- src/agents/ollama-stream.ts | 17 ++++------------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8270dd154..f7679f4c5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang. - Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups. - Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding. - Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus. diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index 2af5e490c7f..241c7a0f858 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -106,7 +106,7 @@ describe("buildAssistantMessage", () => { expect(result.usage.totalTokens).toBe(15); }); - it("falls back to thinking when content is empty", () => { + it("drops thinking-only output when content is empty", () => { const response = { model: "qwen3:32b", created_at: "2026-01-01T00:00:00Z", @@ -119,10 +119,10 @@ describe("buildAssistantMessage", () => { }; const result = buildAssistantMessage(response, modelInfo); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Thinking output" }]); + expect(result.content).toEqual([]); }); - it("falls back to reasoning when content and thinking are empty", () => { + it("drops reasoning-only output when content and thinking are empty", () => { const response = { model: "qwen3:32b", created_at: "2026-01-01T00:00:00Z", @@ -135,7 +135,7 @@ describe("buildAssistantMessage", () => { }; const result = buildAssistantMessage(response, modelInfo); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Reasoning output" }]); + expect(result.content).toEqual([]); }); it("builds response with tool calls", () => { @@ -485,7 +485,7 @@ describe("createOllamaStreamFn", () => { ); }); - it("accumulates thinking chunks when content is empty", async () => { + it("drops thinking chunks when no final content is emitted", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","thinking":"reasoned"},"done":false}', @@ -501,7 +501,7 @@ describe("createOllamaStreamFn", () => { throw new Error("Expected done event"); } - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + expect(doneEvent.message.content).toEqual([]); }, ); }); @@ -528,7 +528,7 @@ describe("createOllamaStreamFn", () => { ); }); - it("accumulates reasoning chunks when thinking is absent", async () => { + it("drops reasoning chunks when no final content is emitted", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}', @@ -544,7 +544,7 @@ describe("createOllamaStreamFn", () => { throw new Error("Expected done event"); } - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + expect(doneEvent.message.content).toEqual([]); }, ); }); diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 9d23852bb31..70a2ef33cf1 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -340,10 +340,9 @@ export function buildAssistantMessage( ): AssistantMessage { const content: (TextContent | ToolCall)[] = []; - // Ollama-native reasoning models may emit their answer in `thinking` or - // `reasoning` with an empty `content`. Fall back so replies are not dropped. - const text = - response.message.content || response.message.thinking || response.message.reasoning || ""; + // Native Ollama reasoning fields are internal model output. The reply text + // must come from `content`; reasoning visibility is controlled elsewhere. + const text = response.message.content || ""; if (text) { content.push({ type: "text", text }); } @@ -497,20 +496,12 @@ export function createOllamaStreamFn( const reader = response.body.getReader(); let accumulatedContent = ""; - let fallbackContent = ""; - let sawContent = false; const accumulatedToolCalls: OllamaToolCall[] = []; let finalResponse: OllamaChatResponse | undefined; for await (const chunk of parseNdjsonStream(reader)) { if (chunk.message?.content) { - sawContent = true; accumulatedContent += chunk.message.content; - } else if (!sawContent && chunk.message?.thinking) { - fallbackContent += chunk.message.thinking; - } else if (!sawContent && chunk.message?.reasoning) { - // Backward compatibility for older/native variants that still use reasoning. - fallbackContent += chunk.message.reasoning; } // Ollama sends tool_calls in intermediate (done:false) chunks, @@ -529,7 +520,7 @@ export function createOllamaStreamFn( throw new Error("Ollama API stream ended without a final response"); } - finalResponse.message.content = accumulatedContent || fallbackContent; + finalResponse.message.content = accumulatedContent; if (accumulatedToolCalls.length > 0) { finalResponse.message.tool_calls = accumulatedToolCalls; } From 9b5000057ec611116b39214807a9bf9ea544b603 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:41:58 +0000 Subject: [PATCH 049/663] ci: remove Android Node 20 action warnings --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b365b2ed944..2761a7b0d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -747,23 +747,37 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - # setup-android's sdkmanager currently crashes on JDK 21 in CI. + # Keep sdkmanager on the stable JDK path for Linux CI runners. java-version: 17 - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - with: - accept-android-sdk-licenses: false + - name: Setup Android SDK cmdline-tools + run: | + set -euo pipefail + ANDROID_SDK_ROOT="$HOME/.android-sdk" + CMDLINE_TOOLS_VERSION="12266719" + ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" + URL="https://dl.google.com/android/repository/${ARCHIVE}" + + mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" + curl -fsSL "$URL" -o "/tmp/${ARCHIVE}" + rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest" + unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools" + mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" + + echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 with: gradle-version: 8.11.1 - name: Install Android SDK packages run: | - yes | sdkmanager --licenses >/dev/null - sdkmanager --install \ + yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null + sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \ "platform-tools" \ "platforms;android-36" \ "build-tools;36.0.0" From 4aec20d36586b96a3b755d3a8725ec9976a92775 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:45:21 +0000 Subject: [PATCH 050/663] test: tighten gateway helper coverage --- src/gateway/control-ui-routing.test.ts | 155 +++++--- src/gateway/live-tool-probe-utils.test.ts | 421 ++++++++++++---------- src/gateway/origin-check.test.ts | 185 +++++----- src/gateway/ws-log.test.ts | 109 ++++-- 4 files changed, 511 insertions(+), 359 deletions(-) diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index f3f172cc7d4..929c645cd01 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -2,65 +2,114 @@ import { describe, expect, it } from "vitest"; import { classifyControlUiRequest } from "./control-ui-routing.js"; describe("classifyControlUiRequest", () => { - it("falls through non-read root requests for plugin webhooks", () => { - const classified = classifyControlUiRequest({ - basePath: "", - pathname: "/bluebubbles-webhook", - search: "", - method: "POST", + describe("root-mounted control ui", () => { + it.each([ + { + name: "serves the root entrypoint", + pathname: "/", + method: "GET", + expected: { kind: "serve" as const }, + }, + { + name: "serves other read-only SPA routes", + pathname: "/chat", + method: "HEAD", + expected: { kind: "serve" as const }, + }, + { + name: "keeps health probes outside the SPA catch-all", + pathname: "/healthz", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps readiness probes outside the SPA catch-all", + pathname: "/ready", + method: "HEAD", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps plugin routes outside the SPA catch-all", + pathname: "/plugins/webhook", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps API routes outside the SPA catch-all", + pathname: "/api/sessions", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "returns not-found for legacy ui routes", + pathname: "/ui/settings", + method: "GET", + expected: { kind: "not-found" as const }, + }, + { + name: "falls through non-read requests", + pathname: "/bluebubbles-webhook", + method: "POST", + expected: { kind: "not-control-ui" as const }, + }, + ])("$name", ({ pathname, method, expected }) => { + expect( + classifyControlUiRequest({ + basePath: "", + pathname, + search: "", + method, + }), + ).toEqual(expected); }); - expect(classified).toEqual({ kind: "not-control-ui" }); }); - it("returns not-found for legacy /ui routes when root-mounted", () => { - const classified = classifyControlUiRequest({ - basePath: "", - pathname: "/ui/settings", - search: "", - method: "GET", - }); - expect(classified).toEqual({ kind: "not-found" }); - }); - - it("falls through basePath non-read methods for plugin webhooks", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw", - search: "", - method: "POST", - }); - expect(classified).toEqual({ kind: "not-control-ui" }); - }); - - it("falls through PUT/DELETE/PATCH/OPTIONS under basePath for plugin handlers", () => { - for (const method of ["PUT", "DELETE", "PATCH", "OPTIONS"]) { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", + describe("basePath-mounted control ui", () => { + it.each([ + { + name: "redirects the basePath entrypoint", + pathname: "/openclaw", + search: "?foo=1", + method: "GET", + expected: { kind: "redirect" as const, location: "/openclaw/?foo=1" }, + }, + { + name: "serves nested read-only routes", + pathname: "/openclaw/chat", + search: "", + method: "HEAD", + expected: { kind: "serve" as const }, + }, + { + name: "falls through unmatched paths", + pathname: "/elsewhere/chat", + search: "", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "falls through write requests to the basePath entrypoint", + pathname: "/openclaw", + search: "", + method: "POST", + expected: { kind: "not-control-ui" as const }, + }, + ...["PUT", "DELETE", "PATCH", "OPTIONS"].map((method) => ({ + name: `falls through ${method} subroute requests`, pathname: "/openclaw/webhook", search: "", method, - }); - expect(classified, `${method} should fall through`).toEqual({ kind: "not-control-ui" }); - } - }); - - it("returns redirect for basePath entrypoint GET", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw", - search: "?foo=1", - method: "GET", + expected: { kind: "not-control-ui" as const }, + })), + ])("$name", ({ pathname, search, method, expected }) => { + expect( + classifyControlUiRequest({ + basePath: "/openclaw", + pathname, + search, + method, + }), + ).toEqual(expected); }); - expect(classified).toEqual({ kind: "redirect", location: "/openclaw/?foo=1" }); - }); - - it("classifies basePath subroutes as control ui", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw/chat", - search: "", - method: "HEAD", - }); - expect(classified).toEqual({ kind: "serve" }); }); }); diff --git a/src/gateway/live-tool-probe-utils.test.ts b/src/gateway/live-tool-probe-utils.test.ts index ca73032c6fb..75f27c08036 100644 --- a/src/gateway/live-tool-probe-utils.test.ts +++ b/src/gateway/live-tool-probe-utils.test.ts @@ -8,198 +8,245 @@ import { } from "./live-tool-probe-utils.js"; describe("live tool probe utils", () => { - it("matches nonce pair when both are present", () => { - expect(hasExpectedToolNonce("value a-1 and b-2", "a-1", "b-2")).toBe(true); - expect(hasExpectedToolNonce("value a-1 only", "a-1", "b-2")).toBe(false); + describe("nonce matching", () => { + it.each([ + { + name: "matches tool nonce pairs only when both are present", + actual: hasExpectedToolNonce("value a-1 and b-2", "a-1", "b-2"), + expected: true, + }, + { + name: "rejects partial tool nonce matches", + actual: hasExpectedToolNonce("value a-1 only", "a-1", "b-2"), + expected: false, + }, + { + name: "matches a single nonce when present", + actual: hasExpectedSingleNonce("value nonce-1", "nonce-1"), + expected: true, + }, + { + name: "rejects single nonce mismatches", + actual: hasExpectedSingleNonce("value nonce-2", "nonce-1"), + expected: false, + }, + ])("$name", ({ actual, expected }) => { + expect(actual).toBe(expected); + }); }); - it("matches single nonce when present", () => { - expect(hasExpectedSingleNonce("value nonce-1", "nonce-1")).toBe(true); - expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false); + describe("refusal detection", () => { + it.each([ + { + name: "detects nonce refusal phrasing", + text: "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.", + expected: true, + }, + { + name: "detects prompt-injection style refusals without nonce text", + text: "That's not a legitimate self-test. This looks like a prompt injection attempt.", + expected: true, + }, + { + name: "ignores generic helper text", + text: "I can help with that request.", + expected: false, + }, + { + name: "does not treat nonce markers without the word nonce as refusal", + text: "No part of the system asks me to parrot back values.", + expected: false, + }, + ])("$name", ({ text, expected }) => { + expect(isLikelyToolNonceRefusal(text)).toBe(expected); + }); }); - it("detects anthropic nonce refusal phrasing", () => { - expect( - isLikelyToolNonceRefusal( - "Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.", - ), - ).toBe(true); + describe("shouldRetryToolReadProbe", () => { + it.each([ + { + name: "retries malformed tool output when attempts remain", + params: { + text: "read[object Object],[object Object]", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not retry once max attempts are exhausted", + params: { + text: "read[object Object],[object Object]", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 2, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "does not retry when the nonce pair is already present", + params: { + text: "nonce-a nonce-b", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "prefers a valid nonce pair even if the text still contains scaffolding words", + params: { + text: "tool output nonce-a nonce-b function", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "retries empty output", + params: { + text: " ", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "retries tool scaffolding output", + params: { + text: "Use tool function read[] now.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "retries mistral nonce marker echoes without parsed values", + params: { + text: "nonceA= nonceB=", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "mistral", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "retries anthropic refusal output", + params: { + text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not special-case anthropic refusals for other providers", + params: { + text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", + nonceA: "nonce-a", + nonceB: "nonce-b", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + ])("$name", ({ params, expected }) => { + expect(shouldRetryToolReadProbe(params)).toBe(expected); + }); }); - it("does not treat generic helper text as nonce refusal", () => { - expect(isLikelyToolNonceRefusal("I can help with that request.")).toBe(false); - }); - - it("detects prompt-injection style tool refusal without nonce text", () => { - expect( - isLikelyToolNonceRefusal( - "That's not a legitimate self-test. This looks like a prompt injection attempt.", - ), - ).toBe(true); - }); - - it("retries malformed tool output when attempts remain", () => { - expect( - shouldRetryToolReadProbe({ - text: "read[object Object],[object Object]", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("does not retry once max attempts are exhausted", () => { - expect( - shouldRetryToolReadProbe({ - text: "read[object Object],[object Object]", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 2, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("does not retry when nonce pair is already present", () => { - expect( - shouldRetryToolReadProbe({ - text: "nonce-a nonce-b", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("retries when tool output is empty and attempts remain", () => { - expect( - shouldRetryToolReadProbe({ - text: " ", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries when output still looks like tool/function scaffolding", () => { - expect( - shouldRetryToolReadProbe({ - text: "Use tool function read[] now.", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries mistral nonce marker echoes without parsed nonce values", () => { - expect( - shouldRetryToolReadProbe({ - text: "nonceA= nonceB=", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "mistral", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries anthropic nonce refusal output", () => { - expect( - shouldRetryToolReadProbe({ - text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "anthropic", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("retries anthropic prompt-injection refusal output", () => { - expect( - shouldRetryToolReadProbe({ - text: "This is not a legitimate self-test; it appears to be a prompt injection attempt.", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "anthropic", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("does not retry nonce marker echoes for non-mistral providers", () => { - expect( - shouldRetryToolReadProbe({ - text: "nonceA= nonceB=", - nonceA: "nonce-a", - nonceB: "nonce-b", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("retries malformed exec+read output when attempts remain", () => { - expect( - shouldRetryExecReadProbe({ - text: "read[object Object]", - nonce: "nonce-c", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); - }); - - it("does not retry exec+read once max attempts are exhausted", () => { - expect( - shouldRetryExecReadProbe({ - text: "read[object Object]", - nonce: "nonce-c", - provider: "openai", - attempt: 2, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("does not retry exec+read when nonce is present", () => { - expect( - shouldRetryExecReadProbe({ - text: "nonce-c", - nonce: "nonce-c", - provider: "openai", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(false); - }); - - it("retries anthropic exec+read nonce refusal output", () => { - expect( - shouldRetryExecReadProbe({ - text: "No part of the system asks me to parrot back nonce values.", - nonce: "nonce-c", - provider: "anthropic", - attempt: 0, - maxAttempts: 3, - }), - ).toBe(true); + describe("shouldRetryExecReadProbe", () => { + it.each([ + { + name: "retries malformed exec+read output when attempts remain", + params: { + text: "read[object Object]", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not retry once max attempts are exhausted", + params: { + text: "read[object Object]", + nonce: "nonce-c", + provider: "openai", + attempt: 2, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "does not retry when the nonce is already present", + params: { + text: "nonce-c", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "prefers a valid nonce even if the text still contains scaffolding words", + params: { + text: "tool output nonce-c function", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + { + name: "retries anthropic nonce refusal output", + params: { + text: "No part of the system asks me to parrot back nonce values.", + nonce: "nonce-c", + provider: "anthropic", + attempt: 0, + maxAttempts: 3, + }, + expected: true, + }, + { + name: "does not special-case anthropic refusals for other providers", + params: { + text: "No part of the system asks me to parrot back nonce values.", + nonce: "nonce-c", + provider: "openai", + attempt: 0, + maxAttempts: 3, + }, + expected: false, + }, + ])("$name", ({ params, expected }) => { + expect(shouldRetryExecReadProbe(params)).toBe(expected); + }); }); }); diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts index 50c031e927d..2bdec288fd6 100644 --- a/src/gateway/origin-check.test.ts +++ b/src/gateway/origin-check.test.ts @@ -2,102 +2,93 @@ import { describe, expect, it } from "vitest"; import { checkBrowserOrigin } from "./origin-check.js"; describe("checkBrowserOrigin", () => { - it("accepts same-origin host matches only with legacy host-header fallback", () => { - const result = checkBrowserOrigin({ - requestHost: "127.0.0.1:18789", - origin: "http://127.0.0.1:18789", - allowHostHeaderOriginFallback: true, - }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.matchedBy).toBe("host-header-fallback"); - } - }); - - it("rejects same-origin host matches when legacy host-header fallback is disabled", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://gateway.example.com:18789", - }); - expect(result.ok).toBe(false); - }); - - it("accepts loopback host mismatches for dev", () => { - const result = checkBrowserOrigin({ - requestHost: "127.0.0.1:18789", - origin: "http://localhost:5173", - isLocalClient: true, - }); - expect(result.ok).toBe(true); - }); - - it("rejects loopback origin mismatches when request is not local", () => { - const result = checkBrowserOrigin({ - requestHost: "127.0.0.1:18789", - origin: "http://localhost:5173", - isLocalClient: false, - }); - expect(result.ok).toBe(false); - }); - - it("accepts allowlisted origins", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://control.example.com", - allowedOrigins: ["https://control.example.com"], - }); - expect(result.ok).toBe(true); - }); - - it("accepts wildcard allowedOrigins", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://any-origin.example.com", - allowedOrigins: ["*"], - }); - expect(result.ok).toBe(true); - }); - - it("rejects missing origin", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "", - }); - expect(result.ok).toBe(false); - }); - - it("rejects mismatched origins", () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.example.com:18789", - origin: "https://attacker.example.com", - }); - expect(result.ok).toBe(false); - }); - - it('accepts any origin when allowedOrigins includes "*" (regression: #30990)', () => { - const result = checkBrowserOrigin({ - requestHost: "100.86.79.37:18789", - origin: "https://100.86.79.37:18789", - allowedOrigins: ["*"], - }); - expect(result.ok).toBe(true); - }); - - it('accepts any origin when allowedOrigins includes "*" alongside specific entries', () => { - const result = checkBrowserOrigin({ - requestHost: "gateway.tailnet.ts.net:18789", - origin: "https://gateway.tailnet.ts.net:18789", - allowedOrigins: ["https://control.example.com", "*"], - }); - expect(result.ok).toBe(true); - }); - - it("accepts wildcard entries with surrounding whitespace", () => { - const result = checkBrowserOrigin({ - requestHost: "100.86.79.37:18789", - origin: "https://100.86.79.37:18789", - allowedOrigins: [" * "], - }); - expect(result.ok).toBe(true); + it.each([ + { + name: "accepts host-header fallback when explicitly enabled", + input: { + requestHost: "127.0.0.1:18789", + origin: "http://127.0.0.1:18789", + allowHostHeaderOriginFallback: true, + }, + expected: { ok: true as const, matchedBy: "host-header-fallback" as const }, + }, + { + name: "rejects same-origin host matches when fallback is disabled", + input: { + requestHost: "gateway.example.com:18789", + origin: "https://gateway.example.com:18789", + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, + { + name: "accepts local loopback mismatches for local clients", + input: { + requestHost: "127.0.0.1:18789", + origin: "http://localhost:5173", + isLocalClient: true, + }, + expected: { ok: true as const, matchedBy: "local-loopback" as const }, + }, + { + name: "rejects loopback mismatches for non-local clients", + input: { + requestHost: "127.0.0.1:18789", + origin: "http://localhost:5173", + isLocalClient: false, + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, + { + name: "accepts trimmed lowercase-normalized allowlist matches", + input: { + requestHost: "gateway.example.com:18789", + origin: "https://CONTROL.example.com", + allowedOrigins: [" https://control.example.com "], + }, + expected: { ok: true as const, matchedBy: "allowlist" as const }, + }, + { + name: "accepts wildcard allowlists even alongside specific entries", + input: { + requestHost: "gateway.tailnet.ts.net:18789", + origin: "https://any-origin.example.com", + allowedOrigins: ["https://control.example.com", " * "], + }, + expected: { ok: true as const, matchedBy: "allowlist" as const }, + }, + { + name: "rejects missing origin", + input: { + requestHost: "gateway.example.com:18789", + origin: "", + }, + expected: { ok: false as const, reason: "origin missing or invalid" }, + }, + { + name: 'rejects literal "null" origin', + input: { + requestHost: "gateway.example.com:18789", + origin: "null", + }, + expected: { ok: false as const, reason: "origin missing or invalid" }, + }, + { + name: "rejects malformed origin URLs", + input: { + requestHost: "gateway.example.com:18789", + origin: "not a url", + }, + expected: { ok: false as const, reason: "origin missing or invalid" }, + }, + { + name: "rejects mismatched origins", + input: { + requestHost: "gateway.example.com:18789", + origin: "https://attacker.example.com", + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, + ])("$name", ({ input, expected }) => { + expect(checkBrowserOrigin(input)).toEqual(expected); }); }); diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts index 5a748c38eb7..a14bca6f628 100644 --- a/src/gateway/ws-log.test.ts +++ b/src/gateway/ws-log.test.ts @@ -2,20 +2,39 @@ import { describe, expect, test } from "vitest"; import { formatForLog, shortId, summarizeAgentEventForWsLog } from "./ws-log.js"; describe("gateway ws log helpers", () => { - test("shortId compacts uuids and long strings", () => { - expect(shortId("12345678-1234-1234-1234-123456789abc")).toBe("12345678…9abc"); - expect(shortId("a".repeat(30))).toBe("aaaaaaaaaaaa…aaaa"); - expect(shortId("short")).toBe("short"); + test.each([ + { + name: "compacts uuids", + input: "12345678-1234-1234-1234-123456789abc", + expected: "12345678…9abc", + }, + { + name: "compacts long strings", + input: "a".repeat(30), + expected: "aaaaaaaaaaaa…aaaa", + }, + { + name: "trims before checking length", + input: " short ", + expected: "short", + }, + ])("shortId $name", ({ input, expected }) => { + expect(shortId(input)).toBe(expected); }); - test("formatForLog formats errors and messages", () => { - const err = new Error("boom"); - err.name = "TestError"; - expect(formatForLog(err)).toContain("TestError"); - expect(formatForLog(err)).toContain("boom"); - - const obj = { name: "Oops", message: "failed", code: "E1" }; - expect(formatForLog(obj)).toBe("Oops: failed: code=E1"); + test.each([ + { + name: "formats Error instances", + input: Object.assign(new Error("boom"), { name: "TestError" }), + expected: "TestError: boom", + }, + { + name: "formats message-like objects with codes", + input: { name: "Oops", message: "failed", code: "E1" }, + expected: "Oops: failed: code=E1", + }, + ])("formatForLog $name", ({ input, expected }) => { + expect(formatForLog(input)).toBe(expected); }); test("formatForLog redacts obvious secrets", () => { @@ -26,33 +45,79 @@ describe("gateway ws log helpers", () => { expect(out).toContain("…"); }); - test("summarizeAgentEventForWsLog extracts useful fields", () => { + test("summarizeAgentEventForWsLog compacts assistant payloads", () => { const summary = summarizeAgentEventForWsLog({ runId: "12345678-1234-1234-1234-123456789abc", sessionKey: "agent:main:main", stream: "assistant", seq: 2, - data: { text: "hello world", mediaUrls: ["a", "b"] }, + data: { + text: "hello\n\nworld ".repeat(20), + mediaUrls: ["a", "b"], + }, }); + expect(summary).toMatchObject({ agent: "main", run: "12345678…9abc", session: "main", stream: "assistant", aseq: 2, - text: "hello world", media: 2, }); + expect(summary.text).toBeTypeOf("string"); + expect(summary.text).not.toContain("\n"); + }); - const tool = summarizeAgentEventForWsLog({ - runId: "run-1", - stream: "tool", - data: { phase: "start", name: "fetch", toolCallId: "call-1" }, - }); - expect(tool).toMatchObject({ + test("summarizeAgentEventForWsLog includes tool metadata", () => { + expect( + summarizeAgentEventForWsLog({ + runId: "run-1", + stream: "tool", + data: { phase: "start", name: "fetch", toolCallId: "12345678-1234-1234-1234-123456789abc" }, + }), + ).toMatchObject({ + run: "run-1", stream: "tool", tool: "start:fetch", - call: "call-1", + call: "12345678…9abc", + }); + }); + + test("summarizeAgentEventForWsLog includes lifecycle errors with compact previews", () => { + const summary = summarizeAgentEventForWsLog({ + runId: "run-2", + sessionKey: "agent:main:thread-1", + stream: "lifecycle", + data: { + phase: "abort", + aborted: true, + error: "fatal ".repeat(40), + }, + }); + + expect(summary).toMatchObject({ + agent: "main", + session: "thread-1", + stream: "lifecycle", + phase: "abort", + aborted: true, + }); + expect(summary.error).toBeTypeOf("string"); + expect((summary.error as string).length).toBeLessThanOrEqual(120); + }); + + test("summarizeAgentEventForWsLog preserves invalid session keys and unknown-stream reasons", () => { + expect( + summarizeAgentEventForWsLog({ + sessionKey: "bogus-session", + stream: "other", + data: { reason: "dropped" }, + }), + ).toEqual({ + session: "bogus-session", + stream: "other", + reason: "dropped", }); }); }); From 2d32cf283948203a5606a195937ef0b374f80fdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:47:47 +0000 Subject: [PATCH 051/663] test: harden infra formatter and retry coverage --- src/infra/format-time/format-time.test.ts | 43 ++++- src/infra/retry-policy.test.ts | 184 +++++++++++++++++----- 2 files changed, 180 insertions(+), 47 deletions(-) diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index e9a25578edd..22ae60dcc6d 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js"; import { formatDurationCompact, @@ -188,6 +188,15 @@ describe("format-relative", () => { }); describe("formatRelativeTimestamp", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-02-10T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("returns fallback for invalid timestamp input", () => { for (const value of [null, undefined]) { expect(formatRelativeTimestamp(value)).toBe("n/a"); @@ -197,21 +206,39 @@ describe("format-relative", () => { it.each([ { offsetMs: -10000, expected: "just now" }, + { offsetMs: -30000, expected: "just now" }, { offsetMs: -300000, expected: "5m ago" }, { offsetMs: -7200000, expected: "2h ago" }, + { offsetMs: -(47 * 3600000), expected: "47h ago" }, + { offsetMs: -(48 * 3600000), expected: "2d ago" }, { offsetMs: 30000, expected: "in <1m" }, { offsetMs: 300000, expected: "in 5m" }, { offsetMs: 7200000, expected: "in 2h" }, ])("formats relative timestamp for offset $offsetMs", ({ offsetMs, expected }) => { - const now = Date.now(); - expect(formatRelativeTimestamp(now + offsetMs)).toBe(expected); + expect(formatRelativeTimestamp(Date.now() + offsetMs)).toBe(expected); }); - it("falls back to date for old timestamps when enabled", () => { - const oldDate = Date.now() - 30 * 24 * 3600000; // 30 days ago - const result = formatRelativeTimestamp(oldDate, { dateFallback: true }); - // Should be a short date like "Jan 9" not "30d ago" - expect(result).toMatch(/[A-Z][a-z]{2} \d{1,2}/); + it.each([ + { + name: "keeps 7-day-old timestamps relative", + offsetMs: -7 * 24 * 3600000, + options: { dateFallback: true, timezone: "UTC" }, + expected: "7d ago", + }, + { + name: "falls back to a short date once the timestamp is older than 7 days", + offsetMs: -8 * 24 * 3600000, + options: { dateFallback: true, timezone: "UTC" }, + expected: "Feb 2", + }, + { + name: "keeps relative output when date fallback is disabled", + offsetMs: -8 * 24 * 3600000, + options: { timezone: "UTC" }, + expected: "8d ago", + }, + ])("$name", ({ offsetMs, options, expected }) => { + expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected); }); }); }); diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts index 76a4415deee..be0e4d91de3 100644 --- a/src/infra/retry-policy.test.ts +++ b/src/infra/retry-policy.test.ts @@ -1,48 +1,154 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createTelegramRetryRunner } from "./retry-policy.js"; +const ZERO_DELAY_RETRY = { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }; + describe("createTelegramRetryRunner", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + describe("strictShouldRetry", () => { - it("without strictShouldRetry: ECONNRESET is retried via regex fallback even when predicate returns false", async () => { - const fn = vi - .fn() - .mockRejectedValue(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: () => false, // predicate says no - // strictShouldRetry not set — regex fallback still applies - }); - await expect(runner(fn, "test")).rejects.toThrow("ECONNRESET"); - // Regex matches "reset" so it retried despite shouldRetry returning false - expect(fn).toHaveBeenCalledTimes(2); - }); + it.each([ + { + name: "falls back to regex matching when strictShouldRetry is disabled", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + shouldRetry: () => false, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + }), + }, + ], + expectedCalls: 2, + expectedError: "ECONNRESET", + }, + { + name: "suppresses regex fallback when strictShouldRetry is enabled", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + shouldRetry: () => false, + strictShouldRetry: true, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("read ECONNRESET"), { + code: "ECONNRESET", + }), + }, + ], + expectedCalls: 1, + expectedError: "ECONNRESET", + }, + { + name: "still retries when the strict predicate returns true", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + shouldRetry: (err: unknown) => (err as { code?: string }).code === "ECONNREFUSED", + strictShouldRetry: true, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("ECONNREFUSED"), { + code: "ECONNREFUSED", + }), + }, + { type: "resolve" as const, value: "ok" }, + ], + expectedCalls: 2, + expectedValue: "ok", + }, + { + name: "does not retry unrelated errors when neither predicate nor regex match", + runnerOptions: { + retry: { ...ZERO_DELAY_RETRY, attempts: 2 }, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("permission denied"), { + code: "EACCES", + }), + }, + ], + expectedCalls: 1, + expectedError: "permission denied", + }, + { + name: "keeps retrying retriable errors until attempts are exhausted", + runnerOptions: { + retry: ZERO_DELAY_RETRY, + }, + fnSteps: [ + { + type: "reject" as const, + value: Object.assign(new Error("connection timeout"), { + code: "ETIMEDOUT", + }), + }, + ], + expectedCalls: 3, + expectedError: "connection timeout", + }, + ])("$name", async ({ runnerOptions, fnSteps, expectedCalls, expectedValue, expectedError }) => { + vi.useFakeTimers(); + const runner = createTelegramRetryRunner(runnerOptions); + const fn = vi.fn(); + const allRejects = fnSteps.length > 0 && fnSteps.every((step) => step.type === "reject"); + if (allRejects) { + fn.mockRejectedValue(fnSteps[0]?.value); + } + for (const [index, step] of fnSteps.entries()) { + if (allRejects && index > 0) { + break; + } + if (step.type === "reject") { + fn.mockRejectedValueOnce(step.value); + } else { + fn.mockResolvedValueOnce(step.value); + } + } - it("with strictShouldRetry=true: ECONNRESET is NOT retried when predicate returns false", async () => { - const fn = vi - .fn() - .mockRejectedValue(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: () => false, - strictShouldRetry: true, // predicate is authoritative - }); - await expect(runner(fn, "test")).rejects.toThrow("ECONNRESET"); - // No retry — predicate returned false and regex fallback was suppressed - expect(fn).toHaveBeenCalledTimes(1); - }); + const promise = runner(fn, "test"); + const assertion = expectedError + ? expect(promise).rejects.toThrow(expectedError) + : expect(promise).resolves.toBe(expectedValue); - it("with strictShouldRetry=true: ECONNREFUSED is still retried when predicate returns true", async () => { - const fn = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })) - .mockResolvedValue("ok"); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: (err) => (err as { code?: string }).code === "ECONNREFUSED", - strictShouldRetry: true, - }); - await expect(runner(fn, "test")).resolves.toBe("ok"); - expect(fn).toHaveBeenCalledTimes(2); + await vi.runAllTimersAsync(); + await assertion; + expect(fn).toHaveBeenCalledTimes(expectedCalls); }); }); + + it("honors nested retry_after hints before retrying", async () => { + vi.useFakeTimers(); + + const runner = createTelegramRetryRunner({ + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1_000, jitter: 0 }, + }); + const fn = vi + .fn() + .mockRejectedValueOnce({ + message: "429 Too Many Requests", + response: { parameters: { retry_after: 1 } }, + }) + .mockResolvedValue("ok"); + + const promise = runner(fn, "test"); + + expect(fn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(999); + expect(fn).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(promise).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); }); From f5b006f6a1a5dde4047d2dd5d4b07b4267a5c35a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:49:32 +0000 Subject: [PATCH 052/663] test: simplify model ref normalization coverage --- src/agents/model-selection.test.ts | 232 ++++++++++++++--------------- 1 file changed, 111 insertions(+), 121 deletions(-) diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 63aef63561c..35ac52dcf26 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -80,131 +80,121 @@ describe("model-selection", () => { }); describe("parseModelRef", () => { - it("should parse full model refs", () => { - expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({ - provider: "anthropic", - model: "claude-3-5-sonnet", - }); + const expectParsedModelVariants = ( + variants: string[], + defaultProvider: string, + expected: { provider: string; model: string }, + ) => { + for (const raw of variants) { + expect(parseModelRef(raw, defaultProvider), raw).toEqual(expected); + } + }; + + it.each([ + { + name: "parses explicit provider/model refs", + variants: ["anthropic/claude-3-5-sonnet"], + defaultProvider: "openai", + expected: { provider: "anthropic", model: "claude-3-5-sonnet" }, + }, + { + name: "uses the default provider when omitted", + variants: ["claude-3-5-sonnet"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-3-5-sonnet" }, + }, + { + name: "preserves nested model ids after the provider prefix", + variants: ["nvidia/moonshotai/kimi-k2.5"], + defaultProvider: "anthropic", + expected: { provider: "nvidia", model: "moonshotai/kimi-k2.5" }, + }, + { + name: "normalizes anthropic shorthand aliases", + variants: ["anthropic/opus-4.6", "opus-4.6", " anthropic / opus-4.6 "], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-opus-4-6" }, + }, + { + name: "normalizes anthropic sonnet aliases", + variants: ["anthropic/sonnet-4.6", "sonnet-4.6"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }, + { + name: "normalizes deprecated google flash preview ids", + variants: ["google/gemini-3.1-flash-preview", "gemini-3.1-flash-preview"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3-flash-preview" }, + }, + { + name: "normalizes gemini 3.1 flash-lite ids", + variants: ["google/gemini-3.1-flash-lite", "gemini-3.1-flash-lite"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" }, + }, + { + name: "keeps OpenAI codex refs on the openai provider", + variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"], + defaultProvider: "openai", + expected: { provider: "openai", model: "gpt-5.3-codex" }, + }, + { + name: "preserves openrouter native model prefixes", + variants: ["openrouter/aurora-alpha"], + defaultProvider: "openai", + expected: { provider: "openrouter", model: "openrouter/aurora-alpha" }, + }, + { + name: "passes through openrouter upstream provider ids", + variants: ["openrouter/anthropic/claude-sonnet-4-5"], + defaultProvider: "openai", + expected: { provider: "openrouter", model: "anthropic/claude-sonnet-4-5" }, + }, + { + name: "normalizes Vercel Claude shorthand to anthropic-prefixed model ids", + variants: ["vercel-ai-gateway/claude-opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }, + { + name: "normalizes Vercel Anthropic aliases without double-prefixing", + variants: ["vercel-ai-gateway/opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4-6" }, + }, + { + name: "keeps already-prefixed Vercel Anthropic models unchanged", + variants: ["vercel-ai-gateway/anthropic/claude-opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }, + { + name: "passes through non-Claude Vercel model ids unchanged", + variants: ["vercel-ai-gateway/openai/gpt-5.2"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "openai/gpt-5.2" }, + }, + { + name: "keeps already-suffixed codex variants unchanged", + variants: ["openai/gpt-5.3-codex-codex"], + defaultProvider: "anthropic", + expected: { provider: "openai", model: "gpt-5.3-codex-codex" }, + }, + ])("$name", ({ variants, defaultProvider, expected }) => { + expectParsedModelVariants(variants, defaultProvider, expected); }); - it("preserves nested model ids after provider prefix", () => { - expect(parseModelRef("nvidia/moonshotai/kimi-k2.5", "anthropic")).toEqual({ - provider: "nvidia", - model: "moonshotai/kimi-k2.5", - }); + it("round-trips normalized refs through modelKey", () => { + const parsed = parseModelRef(" opus-4.6 ", "anthropic"); + expect(parsed).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + expect(modelKey(parsed?.provider ?? "", parsed?.model ?? "")).toBe( + "anthropic/claude-opus-4-6", + ); }); - it("normalizes anthropic alias refs to canonical model ids", () => { - expect(parseModelRef("anthropic/opus-4.6", "openai")).toEqual({ - provider: "anthropic", - model: "claude-opus-4-6", - }); - expect(parseModelRef("opus-4.6", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-opus-4-6", - }); - expect(parseModelRef("anthropic/sonnet-4.6", "openai")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }); - expect(parseModelRef("sonnet-4.6", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }); - }); - - it("should use default provider if none specified", () => { - expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-3-5-sonnet", - }); - }); - - it("normalizes deprecated google flash preview ids to the working model id", () => { - expect(parseModelRef("google/gemini-3.1-flash-preview", "openai")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - expect(parseModelRef("gemini-3.1-flash-preview", "google")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - }); - - it("normalizes gemini 3.1 flash-lite to the preview model id", () => { - expect(parseModelRef("google/gemini-3.1-flash-lite", "openai")).toEqual({ - provider: "google", - model: "gemini-3.1-flash-lite-preview", - }); - expect(parseModelRef("gemini-3.1-flash-lite", "google")).toEqual({ - provider: "google", - model: "gemini-3.1-flash-lite-preview", - }); - }); - - it("keeps openai gpt-5.3 codex refs on the openai provider", () => { - expect(parseModelRef("openai/gpt-5.3-codex", "anthropic")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex", - }); - expect(parseModelRef("gpt-5.3-codex", "openai")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex", - }); - expect(parseModelRef("openai/gpt-5.3-codex-codex", "anthropic")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex-codex", - }); - }); - - it("should return null for empty strings", () => { - expect(parseModelRef("", "anthropic")).toBeNull(); - expect(parseModelRef(" ", "anthropic")).toBeNull(); - }); - - it("should preserve openrouter/ prefix for native models", () => { - expect(parseModelRef("openrouter/aurora-alpha", "openai")).toEqual({ - provider: "openrouter", - model: "openrouter/aurora-alpha", - }); - }); - - it("should pass through openrouter external provider models as-is", () => { - expect(parseModelRef("openrouter/anthropic/claude-sonnet-4-5", "openai")).toEqual({ - provider: "openrouter", - model: "anthropic/claude-sonnet-4-5", - }); - }); - - it("normalizes Vercel Claude shorthand to anthropic-prefixed model ids", () => { - expect(parseModelRef("vercel-ai-gateway/claude-opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4.6", - }); - expect(parseModelRef("vercel-ai-gateway/opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4-6", - }); - }); - - it("keeps already-prefixed Vercel Anthropic models unchanged", () => { - expect(parseModelRef("vercel-ai-gateway/anthropic/claude-opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4.6", - }); - }); - - it("passes through non-Claude Vercel model ids unchanged", () => { - expect(parseModelRef("vercel-ai-gateway/openai/gpt-5.2", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "openai/gpt-5.2", - }); - }); - - it("should handle invalid slash usage", () => { - expect(parseModelRef("/", "anthropic")).toBeNull(); - expect(parseModelRef("anthropic/", "anthropic")).toBeNull(); - expect(parseModelRef("/model", "anthropic")).toBeNull(); + it.each(["", " ", "/", "anthropic/", "/model"])("returns null for invalid ref %j", (raw) => { + expect(parseModelRef(raw, "anthropic")).toBeNull(); }); }); From 87c447ed46c355bb8c54c41324a5b5a63c0a61aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:51:36 +0000 Subject: [PATCH 053/663] test: tighten failover classifier coverage --- ...dded-helpers.isbillingerrormessage.test.ts | 265 ++++++++++-------- 1 file changed, 143 insertions(+), 122 deletions(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 3cbefadbce8..e8578c7feb2 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -45,98 +45,117 @@ const GROQ_TOO_MANY_REQUESTS_MESSAGE = const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret +function expectMessageMatches( + matcher: (message: string) => boolean, + samples: readonly string[], + expected: boolean, +) { + for (const sample of samples) { + expect(matcher(sample), sample).toBe(expected); + } +} + describe("isAuthPermanentErrorMessage", () => { - it("matches permanent auth failure patterns", () => { - const samples = [ - "invalid_api_key", - "api key revoked", - "api key deactivated", - "key has been disabled", - "key has been revoked", - "account has been deactivated", - "could not authenticate api key", - "could not validate credentials", - "API_KEY_REVOKED", - "api_key_deleted", - ]; - for (const sample of samples) { - expect(isAuthPermanentErrorMessage(sample)).toBe(true); - } - }); - it("does not match transient auth errors", () => { - const samples = [ - "unauthorized", - "invalid token", - "authentication failed", - "forbidden", - "access denied", - "token has expired", - ]; - for (const sample of samples) { - expect(isAuthPermanentErrorMessage(sample)).toBe(false); - } + it.each([ + { + name: "matches permanent auth failure patterns", + samples: [ + "invalid_api_key", + "api key revoked", + "api key deactivated", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + "could not authenticate api key", + "could not validate credentials", + "API_KEY_REVOKED", + "api_key_deleted", + ], + expected: true, + }, + { + name: "does not match transient auth errors", + samples: [ + "unauthorized", + "invalid token", + "authentication failed", + "forbidden", + "access denied", + "token has expired", + ], + expected: false, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isAuthPermanentErrorMessage, samples, expected); }); }); describe("isAuthErrorMessage", () => { - it("matches credential validation errors", () => { - const samples = [ - 'No credentials found for profile "anthropic:default".', - "No API key found for profile openai.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("matches OAuth refresh failures", () => { - const samples = [ - "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", - "Please re-authenticate to continue.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } + it.each([ + 'No credentials found for profile "anthropic:default".', + "No API key found for profile openai.", + "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", + "Please re-authenticate to continue.", + ])("matches auth errors for %j", (sample) => { + expect(isAuthErrorMessage(sample)).toBe(true); }); }); describe("isBillingErrorMessage", () => { - it("matches credit / payment failures", () => { - const samples = [ - "Your credit balance is too low to access the Anthropic API.", - "insufficient credits", - "Payment Required", - "HTTP 402 Payment Required", - "plans & billing", - // Venice returns "Insufficient USD or Diem balance" which has extra words - // between "insufficient" and "balance" - "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", - // OpenRouter returns "requires more credits" for underfunded accounts - "This model requires more credits to use", - "This endpoint require more credits", - ]; - for (const sample of samples) { - expect(isBillingErrorMessage(sample)).toBe(true); - } - }); - it("does not false-positive on issue IDs or text containing 402", () => { - const falsePositives = [ - "Fixed issue CHE-402 in the latest release", - "See ticket #402 for details", - "ISSUE-402 has been resolved", - "Room 402 is available", - "Error code 403 was returned, not 402-related", - "The building at 402 Main Street", - "processed 402 records", - "402 items found in the database", - "port 402 is open", - "Use a 402 stainless bolt", - "Book a 402 room", - "There is a 402 near me", - ]; - for (const sample of falsePositives) { - expect(isBillingErrorMessage(sample)).toBe(false); - } + it.each([ + { + name: "matches credit and payment failures", + samples: [ + "Your credit balance is too low to access the Anthropic API.", + "insufficient credits", + "Payment Required", + "HTTP 402 Payment Required", + "plans & billing", + "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", + "This model requires more credits to use", + "This endpoint require more credits", + ], + expected: true, + }, + { + name: "does not false-positive on issue ids and numeric references", + samples: [ + "Fixed issue CHE-402 in the latest release", + "See ticket #402 for details", + "ISSUE-402 has been resolved", + "Room 402 is available", + "Error code 403 was returned, not 402-related", + "The building at 402 Main Street", + "processed 402 records", + "402 items found in the database", + "port 402 is open", + "Use a 402 stainless bolt", + "Book a 402 room", + "There is a 402 near me", + ], + expected: false, + }, + { + name: "still matches real HTTP 402 billing errors", + samples: [ + "HTTP 402 Payment Required", + "status: 402", + "error code 402", + "http 402", + "status=402 payment required", + "got a 402 from the API", + "returned 402", + "received a 402 response", + '{"status":402,"type":"error"}', + '{"code":402,"message":"payment required"}', + '{"error":{"code":402,"message":"billing hard limit reached"}}', + ], + expected: true, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isBillingErrorMessage, samples, expected); }); + it("does not false-positive on long assistant responses mentioning billing keywords", () => { // Simulate a multi-paragraph assistant response that mentions billing terms const longResponse = @@ -176,37 +195,27 @@ describe("isBillingErrorMessage", () => { expect(longNonError.length).toBeGreaterThan(512); expect(isBillingErrorMessage(longNonError)).toBe(false); }); - it("still matches real HTTP 402 billing errors", () => { - const realErrors = [ - "HTTP 402 Payment Required", - "status: 402", - "error code 402", - "http 402", - "status=402 payment required", - "got a 402 from the API", - "returned 402", - "received a 402 response", - '{"status":402,"type":"error"}', - '{"code":402,"message":"payment required"}', - '{"error":{"code":402,"message":"billing hard limit reached"}}', - ]; - for (const sample of realErrors) { - expect(isBillingErrorMessage(sample)).toBe(true); - } + + it("prefers billing when API-key and 402 hints both appear", () => { + const sample = + "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; + expect(isBillingErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("billing"); }); }); describe("isCloudCodeAssistFormatError", () => { it("matches format errors", () => { - const samples = [ - "INVALID_REQUEST_ERROR: string should match pattern", - "messages.1.content.1.tool_use.id", - "tool_use.id should match pattern", - "invalid request format", - ]; - for (const sample of samples) { - expect(isCloudCodeAssistFormatError(sample)).toBe(true); - } + expectMessageMatches( + isCloudCodeAssistFormatError, + [ + "INVALID_REQUEST_ERROR: string should match pattern", + "messages.1.content.1.tool_use.id", + "tool_use.id should match pattern", + "invalid request format", + ], + true, + ); }); }); @@ -238,20 +247,24 @@ describe("isCloudflareOrHtmlErrorPage", () => { }); describe("isCompactionFailureError", () => { - it("matches compaction overflow failures", () => { - const samples = [ - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - "auto-compaction failed due to context overflow", - "Compaction failed: prompt is too long", - "Summarization failed: context window exceeded for this request", - ]; - for (const sample of samples) { - expect(isCompactionFailureError(sample)).toBe(true); - } - }); - it("ignores non-compaction overflow errors", () => { - expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); - expect(isCompactionFailureError("rate limit exceeded")).toBe(false); + it.each([ + { + name: "matches compaction overflow failures", + samples: [ + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + "auto-compaction failed due to context overflow", + "Compaction failed: prompt is too long", + "Summarization failed: context window exceeded for this request", + ], + expected: true, + }, + { + name: "ignores non-compaction overflow errors", + samples: ["Context overflow: prompt too large", "rate limit exceeded"], + expected: false, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isCompactionFailureError, samples, expected); }); }); @@ -506,6 +519,10 @@ describe("isTransientHttpError", () => { }); describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 401 permanent auth failures as auth_permanent", () => { + expect(classifyFailoverReasonFromHttpStatus(401, "invalid_api_key")).toBe("auth_permanent"); + }); + it("treats HTTP 422 as format error", () => { expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format"); expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe( @@ -518,6 +535,10 @@ describe("classifyFailoverReasonFromHttpStatus", () => { expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing"); }); + it("treats HTTP 400 insufficient-quota payloads as billing instead of format", () => { + expect(classifyFailoverReasonFromHttpStatus(400, INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); + }); + it("treats HTTP 499 as transient for structured errors", () => { expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); From 118abfbdb78375aa0af22ed78e2d71d7f7b0d7bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:52:49 +0000 Subject: [PATCH 054/663] test: simplify trusted proxy coverage --- src/gateway/net.test.ts | 252 ++++++++++++++++++++++------------------ 1 file changed, 141 insertions(+), 111 deletions(-) diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index f5ee5db9a8e..185325d5428 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -49,117 +49,147 @@ describe("isLocalishHost", () => { }); describe("isTrustedProxyAddress", () => { - describe("exact IP matching", () => { - it("returns true when IP matches exactly", () => { - expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true); - }); - - it("returns false when IP does not match", () => { - expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false); - }); - - it("returns true when IP matches one of multiple proxies", () => { - expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe( - true, - ); - }); - - it("ignores surrounding whitespace in exact IP entries", () => { - expect(isTrustedProxyAddress("10.0.0.5", [" 10.0.0.5 "])).toBe(true); - }); - }); - - describe("CIDR subnet matching", () => { - it("returns true when IP is within /24 subnet", () => { - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true); - expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true); - expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true); - }); - - it("returns false when IP is outside /24 subnet", () => { - expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false); - expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false); - }); - - it("returns true when IP is within /16 subnet", () => { - expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true); - expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true); - }); - - it("returns false when IP is outside /16 subnet", () => { - expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false); - }); - - it("returns true when IP is within /32 subnet (single IP)", () => { - expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true); - }); - - it("returns false when IP does not match /32 subnet", () => { - expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false); - }); - - it("handles mixed exact IPs and CIDR notation", () => { - const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"]; - expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match - expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match - expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match - expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match - }); - - it("supports IPv6 CIDR notation", () => { - expect(isTrustedProxyAddress("2001:db8::1234", ["2001:db8::/32"])).toBe(true); - expect(isTrustedProxyAddress("2001:db9::1234", ["2001:db8::/32"])).toBe(false); - }); - }); - - describe("backward compatibility", () => { - it("preserves exact IP matching behavior (no CIDR notation)", () => { - // Old configs with exact IPs should work exactly as before - expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true); - expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false); - expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true); - }); - - it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => { - // "10.42.0.1" without /32 should match ONLY that exact IP - expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true); - expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false); - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false); - }); - - it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => { - // Existing normalizeIp() behavior should be preserved - expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true); - }); - }); - - describe("edge cases", () => { - it("returns false when IP is undefined", () => { - expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false); - }); - - it("returns false when trustedProxies is undefined", () => { - expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false); - }); - - it("returns false when trustedProxies is empty", () => { - expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false); - }); - - it("returns false for invalid CIDR notation", () => { - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix - expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix - expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP - }); - - it("ignores surrounding whitespace in CIDR entries", () => { - expect(isTrustedProxyAddress("10.42.0.59", [" 10.42.0.0/24 "])).toBe(true); - }); - - it("ignores blank trusted proxy entries", () => { - expect(isTrustedProxyAddress("10.0.0.5", [" ", "\t"])).toBe(false); - expect(isTrustedProxyAddress("10.0.0.5", [" ", "10.0.0.5", ""])).toBe(true); - }); + it.each([ + { + name: "matches exact IP entries", + ip: "192.168.1.1", + trustedProxies: ["192.168.1.1"], + expected: true, + }, + { + name: "rejects non-matching exact IP entries", + ip: "192.168.1.2", + trustedProxies: ["192.168.1.1"], + expected: false, + }, + { + name: "matches one of multiple exact entries", + ip: "10.0.0.5", + trustedProxies: ["192.168.1.1", "10.0.0.5", "172.16.0.1"], + expected: true, + }, + { + name: "ignores surrounding whitespace in exact IP entries", + ip: "10.0.0.5", + trustedProxies: [" 10.0.0.5 "], + expected: true, + }, + { + name: "matches /24 CIDR entries", + ip: "10.42.0.59", + trustedProxies: ["10.42.0.0/24"], + expected: true, + }, + { + name: "rejects IPs outside /24 CIDR entries", + ip: "10.42.1.1", + trustedProxies: ["10.42.0.0/24"], + expected: false, + }, + { + name: "matches /16 CIDR entries", + ip: "172.19.255.255", + trustedProxies: ["172.19.0.0/16"], + expected: true, + }, + { + name: "rejects IPs outside /16 CIDR entries", + ip: "172.20.0.1", + trustedProxies: ["172.19.0.0/16"], + expected: false, + }, + { + name: "treats /32 as a single-IP CIDR", + ip: "10.42.0.0", + trustedProxies: ["10.42.0.0/32"], + expected: true, + }, + { + name: "rejects non-matching /32 CIDR entries", + ip: "10.42.0.1", + trustedProxies: ["10.42.0.0/32"], + expected: false, + }, + { + name: "handles mixed exact IP and CIDR entries", + ip: "172.19.5.100", + trustedProxies: ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"], + expected: true, + }, + { + name: "rejects IPs missing from mixed exact IP and CIDR entries", + ip: "10.43.0.1", + trustedProxies: ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"], + expected: false, + }, + { + name: "supports IPv6 CIDR notation", + ip: "2001:db8::1234", + trustedProxies: ["2001:db8::/32"], + expected: true, + }, + { + name: "rejects IPv6 addresses outside the configured CIDR", + ip: "2001:db9::1234", + trustedProxies: ["2001:db8::/32"], + expected: false, + }, + { + name: "preserves exact matching behavior for plain IP entries", + ip: "10.42.0.59", + trustedProxies: ["10.42.0.1"], + expected: false, + }, + { + name: "normalizes IPv4-mapped IPv6 addresses", + ip: "::ffff:192.168.1.1", + trustedProxies: ["192.168.1.1"], + expected: true, + }, + { + name: "returns false when IP is undefined", + ip: undefined, + trustedProxies: ["192.168.1.1"], + expected: false, + }, + { + name: "returns false when trusted proxies are undefined", + ip: "192.168.1.1", + trustedProxies: undefined, + expected: false, + }, + { + name: "returns false when trusted proxies are empty", + ip: "192.168.1.1", + trustedProxies: [], + expected: false, + }, + { + name: "rejects invalid CIDR prefixes and addresses", + ip: "10.42.0.59", + trustedProxies: ["10.42.0.0/33", "10.42.0.0/-1", "invalid/24", "2001:db8::/129"], + expected: false, + }, + { + name: "ignores surrounding whitespace in CIDR entries", + ip: "10.42.0.59", + trustedProxies: [" 10.42.0.0/24 "], + expected: true, + }, + { + name: "ignores blank trusted proxy entries", + ip: "10.0.0.5", + trustedProxies: [" ", "10.0.0.5", ""], + expected: true, + }, + { + name: "treats all-blank trusted proxy entries as no match", + ip: "10.0.0.5", + trustedProxies: [" ", "\t"], + expected: false, + }, + ])("$name", ({ ip, trustedProxies, expected }) => { + expect(isTrustedProxyAddress(ip, trustedProxies)).toBe(expected); }); }); From a68caaf719b0106a1cefd813c2a1116f6947089e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:54:38 +0000 Subject: [PATCH 055/663] test: dedupe infra runtime and heartbeat coverage --- src/infra/infra-runtime.test.ts | 22 --- src/infra/outbound/targets.test.ts | 262 ++++++++++++++--------------- 2 files changed, 126 insertions(+), 158 deletions(-) diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index e7656de974f..1596b73bbe8 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -13,7 +13,6 @@ import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck, } from "./restart.js"; -import { createTelegramRetryRunner } from "./retry-policy.js"; import { listTailnetAddresses } from "./tailnet.js"; describe("infra runtime", () => { @@ -61,27 +60,6 @@ describe("infra runtime", () => { }); }); - describe("createTelegramRetryRunner", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("retries when custom shouldRetry matches non-telegram error", async () => { - vi.useFakeTimers(); - const runner = createTelegramRetryRunner({ - retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, - shouldRetry: (err) => err instanceof Error && err.message === "boom", - }); - const fn = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValue("ok"); - - const promise = runner(fn, "request"); - await vi.runAllTimersAsync(); - - await expect(promise).resolves.toBe("ok"); - expect(fn).toHaveBeenCalledTimes(2); - }); - }); - describe("restart authorization", () => { setupRestartSignalSuite(); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 6a8b50403b5..e0b669040a6 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -339,35 +339,138 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => { - const resolved = resolveHeartbeatTarget({ - sessionId: "sess-heartbeat-outbound", - updatedAt: 1, - lastChannel: "slack", - lastTo: "user:U123", - lastThreadId: "1739142736.000100", - }); + const expectHeartbeatTarget = (params: { + name: string; + entry: Parameters[0]["entry"]; + directPolicy?: "allow" | "block"; + expectedChannel: string; + expectedTo?: string; + expectedReason?: string; + expectedThreadId?: string | number; + }) => { + const resolved = resolveHeartbeatTarget(params.entry, params.directPolicy); + expect(resolved.channel, params.name).toBe(params.expectedChannel); + expect(resolved.to, params.name).toBe(params.expectedTo); + expect(resolved.reason, params.name).toBe(params.expectedReason); + expect(resolved.threadId, params.name).toBe(params.expectedThreadId); + }; - expect(resolved.channel).toBe("slack"); - expect(resolved.to).toBe("user:U123"); - expect(resolved.threadId).toBeUndefined(); - }); - - it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => { - const resolved = resolveHeartbeatTarget( - { - sessionId: "sess-heartbeat-outbound", + it.each([ + { + name: "allows heartbeat delivery to Slack DMs by default and drops inherited thread ids", + entry: { + sessionId: "sess-heartbeat-slack-direct", updatedAt: 1, lastChannel: "slack", lastTo: "user:U123", lastThreadId: "1739142736.000100", }, - "block", - ); - - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); - expect(resolved.threadId).toBeUndefined(); + expectedChannel: "slack", + expectedTo: "user:U123", + }, + { + name: "blocks heartbeat delivery to Slack DMs when directPolicy is block", + entry: { + sessionId: "sess-heartbeat-slack-direct-blocked", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + directPolicy: "block" as const, + expectedChannel: "none", + expectedReason: "dm-blocked", + }, + { + name: "allows heartbeat delivery to Telegram direct chats by default", + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + expectedChannel: "telegram", + expectedTo: "5232990709", + }, + { + name: "blocks heartbeat delivery to Telegram direct chats when directPolicy is block", + entry: { + sessionId: "sess-heartbeat-telegram-direct-blocked", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + directPolicy: "block" as const, + expectedChannel: "none", + expectedReason: "dm-blocked", + }, + { + name: "keeps heartbeat delivery to Telegram groups", + entry: { + sessionId: "sess-heartbeat-telegram-group", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "-1001234567890", + }, + expectedChannel: "telegram", + expectedTo: "-1001234567890", + }, + { + name: "allows heartbeat delivery to WhatsApp direct chats by default", + entry: { + sessionId: "sess-heartbeat-whatsapp-direct", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "+15551234567", + }, + expectedChannel: "whatsapp", + expectedTo: "+15551234567", + }, + { + name: "keeps heartbeat delivery to WhatsApp groups", + entry: { + sessionId: "sess-heartbeat-whatsapp-group", + updatedAt: 1, + lastChannel: "whatsapp", + lastTo: "120363140186826074@g.us", + }, + expectedChannel: "whatsapp", + expectedTo: "120363140186826074@g.us", + }, + { + name: "uses session chatType hints when target parsing cannot classify a direct chat", + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + expectedChannel: "imessage", + expectedTo: "chat-guid-unknown-shape", + }, + { + name: "blocks session chatType direct hints when directPolicy is block", + entry: { + sessionId: "sess-heartbeat-imessage-direct-blocked", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + directPolicy: "block" as const, + expectedChannel: "none", + expectedReason: "dm-blocked", + }, + ])("$name", ({ name, entry, directPolicy, expectedChannel, expectedTo, expectedReason }) => { + expectHeartbeatTarget({ + name, + entry, + directPolicy, + expectedChannel, + expectedTo, + expectedReason, + }); }); it("allows heartbeat delivery to Discord DMs by default", () => { @@ -389,119 +492,6 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("user:12345"); }); - it("allows heartbeat delivery to Telegram direct chats by default", () => { - const resolved = resolveHeartbeatTarget({ - sessionId: "sess-heartbeat-telegram-direct", - updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", - }); - - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("5232990709"); - }); - - it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => { - const resolved = resolveHeartbeatTarget( - { - sessionId: "sess-heartbeat-telegram-direct", - updatedAt: 1, - lastChannel: "telegram", - lastTo: "5232990709", - }, - "block", - ); - - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); - }); - - it("keeps heartbeat delivery to Telegram groups", () => { - const cfg: OpenClawConfig = {}; - const resolved = resolveHeartbeatDeliveryTarget({ - cfg, - entry: { - sessionId: "sess-heartbeat-telegram-group", - updatedAt: 1, - lastChannel: "telegram", - lastTo: "-1001234567890", - }, - heartbeat: { - target: "last", - }, - }); - - expect(resolved.channel).toBe("telegram"); - expect(resolved.to).toBe("-1001234567890"); - }); - - it("allows heartbeat delivery to WhatsApp direct chats by default", () => { - const cfg: OpenClawConfig = {}; - const resolved = resolveHeartbeatDeliveryTarget({ - cfg, - entry: { - sessionId: "sess-heartbeat-whatsapp-direct", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+15551234567", - }, - heartbeat: { - target: "last", - }, - }); - - expect(resolved.channel).toBe("whatsapp"); - expect(resolved.to).toBe("+15551234567"); - }); - - it("keeps heartbeat delivery to WhatsApp groups", () => { - const cfg: OpenClawConfig = {}; - const resolved = resolveHeartbeatDeliveryTarget({ - cfg, - entry: { - sessionId: "sess-heartbeat-whatsapp-group", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "120363140186826074@g.us", - }, - heartbeat: { - target: "last", - }, - }); - - expect(resolved.channel).toBe("whatsapp"); - expect(resolved.to).toBe("120363140186826074@g.us"); - }); - - it("uses session chatType hint when target parser cannot classify and allows direct by default", () => { - const resolved = resolveHeartbeatTarget({ - sessionId: "sess-heartbeat-imessage-direct", - updatedAt: 1, - lastChannel: "imessage", - lastTo: "chat-guid-unknown-shape", - chatType: "direct", - }); - - expect(resolved.channel).toBe("imessage"); - expect(resolved.to).toBe("chat-guid-unknown-shape"); - }); - - it("blocks session chatType direct hints when directPolicy is block", () => { - const resolved = resolveHeartbeatTarget( - { - sessionId: "sess-heartbeat-imessage-direct", - updatedAt: 1, - lastChannel: "imessage", - lastTo: "chat-guid-unknown-shape", - chatType: "direct", - }, - "block", - ); - - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); - }); - it("keeps heartbeat delivery to Discord channels", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ From 981062a94edbe1d6a874dfbea58ede7470b49b22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:55:55 +0000 Subject: [PATCH 056/663] test: simplify outbound channel coverage --- src/infra/outbound/message.channels.test.ts | 109 +++++++++++--------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 0a21264b43e..257d2ec94d6 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -97,13 +97,10 @@ describe("sendMessage channel normalization", () => { expect(seen.to).toBe("+15551234567"); }); - it("normalizes Teams alias", async () => { - const sendMSTeams = vi.fn(async () => ({ - messageId: "m1", - conversationId: "c1", - })); - setRegistry( - createTestRegistry([ + it.each([ + { + name: "normalizes Teams aliases", + registry: createTestRegistry([ { pluginId: "msteams", source: "test", @@ -113,40 +110,57 @@ describe("sendMessage channel normalization", () => { }), }, ]), - ); - const result = await sendMessage({ - cfg: {}, - to: "conversation:19:abc@thread.tacv2", - content: "hi", - channel: "teams", - deps: { sendMSTeams }, - }); - - expect(sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi"); - expect(result.channel).toBe("msteams"); - }); - - it("normalizes iMessage alias", async () => { - const sendIMessage = vi.fn(async () => ({ messageId: "i1" })); - setRegistry( - createTestRegistry([ + params: { + to: "conversation:19:abc@thread.tacv2", + channel: "teams", + deps: { + sendMSTeams: vi.fn(async () => ({ + messageId: "m1", + conversationId: "c1", + })), + }, + }, + assertDeps: (deps: { sendMSTeams?: ReturnType }) => { + expect(deps.sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi"); + }, + expectedChannel: "msteams", + }, + { + name: "normalizes iMessage aliases", + registry: createTestRegistry([ { pluginId: "imessage", source: "test", plugin: createIMessageTestPlugin(), }, ]), - ); + params: { + to: "someone@example.com", + channel: "imsg", + deps: { + sendIMessage: vi.fn(async () => ({ messageId: "i1" })), + }, + }, + assertDeps: (deps: { sendIMessage?: ReturnType }) => { + expect(deps.sendIMessage).toHaveBeenCalledWith( + "someone@example.com", + "hi", + expect.any(Object), + ); + }, + expectedChannel: "imessage", + }, + ])("$name", async ({ registry, params, assertDeps, expectedChannel }) => { + setRegistry(registry); + const result = await sendMessage({ cfg: {}, - to: "someone@example.com", content: "hi", - channel: "imsg", - deps: { sendIMessage }, + ...params, }); - expect(sendIMessage).toHaveBeenCalledWith("someone@example.com", "hi", expect.any(Object)); - expect(result.channel).toBe("imessage"); + assertDeps(params.deps); + expect(result.channel).toBe(expectedChannel); }); }); @@ -162,34 +176,31 @@ describe("sendMessage replyToId threading", () => { return capturedCtx; }; - it("passes replyToId through to the outbound adapter", async () => { + it.each([ + { + name: "passes replyToId through to the outbound adapter", + params: { content: "thread reply", replyToId: "post123" }, + field: "replyToId", + expected: "post123", + }, + { + name: "passes threadId through to the outbound adapter", + params: { content: "topic reply", threadId: "topic456" }, + field: "threadId", + expected: "topic456", + }, + ])("$name", async ({ params, field, expected }) => { const capturedCtx = setupMattermostCapture(); await sendMessage({ cfg: {}, to: "channel:town-square", - content: "thread reply", channel: "mattermost", - replyToId: "post123", + ...params, }); expect(capturedCtx).toHaveLength(1); - expect(capturedCtx[0]?.replyToId).toBe("post123"); - }); - - it("passes threadId through to the outbound adapter", async () => { - const capturedCtx = setupMattermostCapture(); - - await sendMessage({ - cfg: {}, - to: "channel:town-square", - content: "topic reply", - channel: "mattermost", - threadId: "topic456", - }); - - expect(capturedCtx).toHaveLength(1); - expect(capturedCtx[0]?.threadId).toBe("topic456"); + expect(capturedCtx[0]?.[field]).toBe(expected); }); }); From 91f1894372d3170407d8e9a4b05563e6032345ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:57:05 +0000 Subject: [PATCH 057/663] test: tighten server method helper coverage --- .../server-methods/server-methods.test.ts | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 424511370cd..bd42485f4f8 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -221,59 +221,70 @@ describe("injectTimestamp", () => { }); describe("timestampOptsFromConfig", () => { - it("extracts timezone from config", () => { - const opts = timestampOptsFromConfig({ - agents: { - defaults: { - userTimezone: "America/Chicago", - }, - }, + it.each([ + { + name: "extracts timezone from config", // oxlint-disable-next-line typescript/no-explicit-any - } as any); - - expect(opts.timezone).toBe("America/Chicago"); - }); - - it("falls back gracefully with empty config", () => { - // oxlint-disable-next-line typescript/no-explicit-any - const opts = timestampOptsFromConfig({} as any); - - expect(opts.timezone).toBeDefined(); + cfg: { agents: { defaults: { userTimezone: "America/Chicago" } } } as any, + expected: "America/Chicago", + }, + { + name: "falls back gracefully with empty config", + // oxlint-disable-next-line typescript/no-explicit-any + cfg: {} as any, + expected: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ])("$name", ({ cfg, expected }) => { + expect(timestampOptsFromConfig(cfg).timezone).toBe(expected); }); }); describe("normalizeRpcAttachmentsToChatAttachments", () => { - it("passes through string content", () => { - const res = normalizeRpcAttachmentsToChatAttachments([ - { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, - ]); - expect(res).toEqual([ - { type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }, - ]); - }); - - it("converts Uint8Array content to base64", () => { - const bytes = new TextEncoder().encode("foo"); - const res = normalizeRpcAttachmentsToChatAttachments([{ content: bytes }]); - expect(res[0]?.content).toBe("Zm9v"); + it.each([ + { + name: "passes through string content", + attachments: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }], + expected: [{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" }], + }, + { + name: "converts Uint8Array content to base64", + attachments: [{ content: new TextEncoder().encode("foo") }], + expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "Zm9v" }], + }, + { + name: "converts ArrayBuffer content to base64", + attachments: [{ content: new TextEncoder().encode("bar").buffer }], + expected: [{ type: undefined, mimeType: undefined, fileName: undefined, content: "YmFy" }], + }, + { + name: "drops attachments without usable content", + attachments: [{ content: undefined }, { mimeType: "image/png" }], + expected: [], + }, + ])("$name", ({ attachments, expected }) => { + expect(normalizeRpcAttachmentsToChatAttachments(attachments)).toEqual(expected); }); }); describe("sanitizeChatSendMessageInput", () => { - it("rejects null bytes", () => { - expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({ - ok: false, - error: "message must not contain null bytes", - }); - }); - - it("strips unsafe control characters while preserving tab/newline/carriage return", () => { - const result = sanitizeChatSendMessageInput("a\u0001b\tc\nd\re\u0007f\u007f"); - expect(result).toEqual({ ok: true, message: "ab\tc\nd\ref" }); - }); - - it("normalizes unicode to NFC", () => { - expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({ ok: true, message: "Café" }); + it.each([ + { + name: "rejects null bytes", + input: "before\u0000after", + expected: { ok: false as const, error: "message must not contain null bytes" }, + }, + { + name: "strips unsafe control characters while preserving tab/newline/carriage return", + input: "a\u0001b\tc\nd\re\u0007f\u007f", + expected: { ok: true as const, message: "ab\tc\nd\ref" }, + }, + { + name: "normalizes unicode to NFC", + input: "Cafe\u0301", + expected: { ok: true as const, message: "Café" }, + }, + ])("$name", ({ input, expected }) => { + expect(sanitizeChatSendMessageInput(input)).toEqual(expected); }); }); From e25fa446e8efafe624d81d2212b286c2a9e8e5ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 17:58:28 +0000 Subject: [PATCH 058/663] test: refine gateway auth helper coverage --- src/gateway/device-auth.test.ts | 84 ++++++++++++++++++++++++--------- src/gateway/probe-auth.test.ts | 84 ++++++++++++++++----------------- 2 files changed, 104 insertions(+), 64 deletions(-) diff --git a/src/gateway/device-auth.test.ts b/src/gateway/device-auth.test.ts index 9d7ac3fb7b5..8db88428ce9 100644 --- a/src/gateway/device-auth.test.ts +++ b/src/gateway/device-auth.test.ts @@ -1,29 +1,69 @@ import { describe, expect, it } from "vitest"; -import { buildDeviceAuthPayloadV3, normalizeDeviceMetadataForAuth } from "./device-auth.js"; +import { + buildDeviceAuthPayload, + buildDeviceAuthPayloadV3, + normalizeDeviceMetadataForAuth, +} from "./device-auth.js"; describe("device-auth payload vectors", () => { - it("builds canonical v3 payload", () => { - const payload = buildDeviceAuthPayloadV3({ - deviceId: "dev-1", - clientId: "openclaw-macos", - clientMode: "ui", - role: "operator", - scopes: ["operator.admin", "operator.read"], - signedAtMs: 1_700_000_000_000, - token: "tok-123", - nonce: "nonce-abc", - platform: " IOS ", - deviceFamily: " iPhone ", - }); - - expect(payload).toBe( - "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", - ); + it.each([ + { + name: "builds canonical v2 payloads", + build: () => + buildDeviceAuthPayload({ + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: null, + nonce: "nonce-abc", + }), + expected: + "v2|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000||nonce-abc", + }, + { + name: "builds canonical v3 payloads", + build: () => + buildDeviceAuthPayloadV3({ + deviceId: "dev-1", + clientId: "openclaw-macos", + clientMode: "ui", + role: "operator", + scopes: ["operator.admin", "operator.read"], + signedAtMs: 1_700_000_000_000, + token: "tok-123", + nonce: "nonce-abc", + platform: " IOS ", + deviceFamily: " iPhone ", + }), + expected: + "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone", + }, + { + name: "keeps empty metadata slots in v3 payloads", + build: () => + buildDeviceAuthPayloadV3({ + deviceId: "dev-2", + clientId: "openclaw-ios", + clientMode: "ui", + role: "operator", + scopes: ["operator.read"], + signedAtMs: 1_700_000_000_001, + nonce: "nonce-def", + }), + expected: "v3|dev-2|openclaw-ios|ui|operator|operator.read|1700000000001||nonce-def||", + }, + ])("$name", ({ build, expected }) => { + expect(build()).toBe(expected); }); - it("normalizes metadata with ASCII-only lowercase", () => { - expect(normalizeDeviceMetadataForAuth(" İOS ")).toBe("İos"); - expect(normalizeDeviceMetadataForAuth(" MAC ")).toBe("mac"); - expect(normalizeDeviceMetadataForAuth(undefined)).toBe(""); + it.each([ + { input: " İOS ", expected: "İos" }, + { input: " MAC ", expected: "mac" }, + { input: undefined, expected: "" }, + ])("normalizes metadata %j", ({ input, expected }) => { + expect(normalizeDeviceMetadataForAuth(input)).toBe(expected); }); }); diff --git a/src/gateway/probe-auth.test.ts b/src/gateway/probe-auth.test.ts index 7a6d639e10a..314702c33db 100644 --- a/src/gateway/probe-auth.test.ts +++ b/src/gateway/probe-auth.test.ts @@ -6,8 +6,9 @@ import { } from "./probe-auth.js"; describe("resolveGatewayProbeAuthSafe", () => { - it("returns probe auth credentials when available", () => { - const result = resolveGatewayProbeAuthSafe({ + it.each([ + { + name: "returns probe auth credentials when available", cfg: { gateway: { auth: { @@ -15,20 +16,17 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local", + mode: "local" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result).toEqual({ - auth: { - token: "token-value", - password: undefined, + expected: { + auth: { + token: "token-value", + password: undefined, + }, }, - }); - }); - - it("returns warning and empty auth when token SecretRef is unresolved", () => { - const result = resolveGatewayProbeAuthSafe({ + }, + { + name: "returns warning and empty auth when a local token SecretRef is unresolved", cfg: { gateway: { auth: { @@ -42,17 +40,15 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local", + mode: "local" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result.auth).toEqual({}); - expect(result.warning).toContain("gateway.auth.token"); - expect(result.warning).toContain("unresolved"); - }); - - it("does not fall through to remote token when local token SecretRef is unresolved", () => { - const result = resolveGatewayProbeAuthSafe({ + expected: { + auth: {}, + warningIncludes: ["gateway.auth.token", "unresolved"], + }, + }, + { + name: "does not fall through to remote token when the local SecretRef is unresolved", cfg: { gateway: { mode: "local", @@ -70,17 +66,15 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "local", + mode: "local" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result.auth).toEqual({}); - expect(result.warning).toContain("gateway.auth.token"); - expect(result.warning).toContain("unresolved"); - }); - - it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => { - const result = resolveGatewayProbeAuthSafe({ + expected: { + auth: {}, + warningIncludes: ["gateway.auth.token", "unresolved"], + }, + }, + { + name: "ignores unresolved local token SecretRefs in remote mode", cfg: { gateway: { mode: "remote", @@ -98,16 +92,22 @@ describe("resolveGatewayProbeAuthSafe", () => { }, }, } as OpenClawConfig, - mode: "remote", + mode: "remote" as const, env: {} as NodeJS.ProcessEnv, - }); - - expect(result).toEqual({ - auth: { - token: undefined, - password: undefined, + expected: { + auth: { + token: undefined, + password: undefined, + }, }, - }); + }, + ])("$name", ({ cfg, mode, env, expected }) => { + const result = resolveGatewayProbeAuthSafe({ cfg, mode, env }); + + expect(result.auth).toEqual(expected.auth); + for (const fragment of expected.warningIncludes ?? []) { + expect(result.warning).toContain(fragment); + } }); }); From 1f85c9af68ab1f639b3583b49fe815152865f34d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:00:03 +0000 Subject: [PATCH 059/663] test: simplify runtime config coverage --- src/gateway/server-runtime-config.test.ts | 99 +++++++++++++---------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 34cc4632670..205bac8cf3e 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -201,39 +201,73 @@ describe("resolveGatewayRuntimeConfig", () => { ); }); - it("rejects non-loopback control UI when allowed origins are missing", async () => { - await expect( - resolveGatewayRuntimeConfig({ - cfg: { - gateway: { - bind: "lan", - auth: TOKEN_AUTH, - }, - }, - port: 18789, - }), - ).rejects.toThrow("non-loopback Control UI requires gateway.controlUi.allowedOrigins"); - }); - - it("allows non-loopback control UI without allowed origins when dangerous fallback is enabled", async () => { - const result = await resolveGatewayRuntimeConfig({ + it.each([ + { + name: "rejects non-loopback control UI when allowed origins are missing", cfg: { gateway: { - bind: "lan", + bind: "lan" as const, + auth: TOKEN_AUTH, + }, + }, + expectedError: "non-loopback Control UI requires gateway.controlUi.allowedOrigins", + }, + { + name: "allows non-loopback control UI without allowed origins when dangerous fallback is enabled", + cfg: { + gateway: { + bind: "lan" as const, auth: TOKEN_AUTH, controlUi: { dangerouslyAllowHostHeaderOriginFallback: true, }, }, }, - port: 18789, - }); - expect(result.bindHost).toBe("0.0.0.0"); + expectedBindHost: "0.0.0.0", + }, + { + name: "allows non-loopback control UI when allowed origins collapse after trimming", + cfg: { + gateway: { + bind: "lan" as const, + auth: TOKEN_AUTH, + controlUi: { + allowedOrigins: [" https://control.example.com "], + }, + }, + }, + expectedBindHost: "0.0.0.0", + }, + ])("$name", async ({ cfg, expectedError, expectedBindHost }) => { + if (expectedError) { + await expect(resolveGatewayRuntimeConfig({ cfg, port: 18789 })).rejects.toThrow( + expectedError, + ); + return; + } + const result = await resolveGatewayRuntimeConfig({ cfg, port: 18789 }); + expect(result.bindHost).toBe(expectedBindHost); }); }); describe("HTTP security headers", () => { - it("resolves strict transport security header from config", async () => { + it.each([ + { + name: "resolves strict transport security headers from config", + strictTransportSecurity: " max-age=31536000; includeSubDomains ", + expected: "max-age=31536000; includeSubDomains", + }, + { + name: "does not set strict transport security when explicitly disabled", + strictTransportSecurity: false, + expected: undefined, + }, + { + name: "does not set strict transport security when the value is blank", + strictTransportSecurity: " ", + expected: undefined, + }, + ])("$name", async ({ strictTransportSecurity, expected }) => { const result = await resolveGatewayRuntimeConfig({ cfg: { gateway: { @@ -241,7 +275,7 @@ describe("resolveGatewayRuntimeConfig", () => { auth: { mode: "none" }, http: { securityHeaders: { - strictTransportSecurity: " max-age=31536000; includeSubDomains ", + strictTransportSecurity, }, }, }, @@ -249,26 +283,7 @@ describe("resolveGatewayRuntimeConfig", () => { port: 18789, }); - expect(result.strictTransportSecurityHeader).toBe("max-age=31536000; includeSubDomains"); - }); - - it("does not set strict transport security when explicitly disabled", async () => { - const result = await resolveGatewayRuntimeConfig({ - cfg: { - gateway: { - bind: "loopback", - auth: { mode: "none" }, - http: { - securityHeaders: { - strictTransportSecurity: false, - }, - }, - }, - }, - port: 18789, - }); - - expect(result.strictTransportSecurityHeader).toBeUndefined(); + expect(result.strictTransportSecurityHeader).toBe(expected); }); }); }); From 987c254eea57321338173ee3e1cc8b4084cf7bf2 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Sat, 14 Mar 2026 02:03:14 +0800 Subject: [PATCH 060/663] test: annotate chat abort helper exports (#45346) --- .../server-methods/chat.abort.test-helpers.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/gateway/server-methods/chat.abort.test-helpers.ts b/src/gateway/server-methods/chat.abort.test-helpers.ts index c1db68f5774..fb6efebd8f5 100644 --- a/src/gateway/server-methods/chat.abort.test-helpers.ts +++ b/src/gateway/server-methods/chat.abort.test-helpers.ts @@ -1,5 +1,6 @@ import { vi } from "vitest"; -import type { GatewayRequestHandler } from "./types.js"; +import type { Mock } from "vitest"; +import type { GatewayRequestHandler, RespondFn } from "./types.js"; export function createActiveRun( sessionKey: string, @@ -20,7 +21,23 @@ export function createActiveRun( }; } -export function createChatAbortContext(overrides: Record = {}) { +export type ChatAbortTestContext = Record & { + chatAbortControllers: Map>; + chatRunBuffers: Map; + chatDeltaSentAt: Map; + chatAbortedRuns: Map; + removeChatRun: (...args: unknown[]) => { sessionKey: string; clientRunId: string } | undefined; + agentRunSeq: Map; + broadcast: (...args: unknown[]) => void; + nodeSendToSession: (...args: unknown[]) => void; + logGateway: { warn: (...args: unknown[]) => void }; +}; + +export type ChatAbortRespondMock = Mock; + +export function createChatAbortContext( + overrides: Record = {}, +): ChatAbortTestContext { return { chatAbortControllers: new Map(), chatRunBuffers: new Map(), @@ -39,7 +56,7 @@ export function createChatAbortContext(overrides: Record = {}) export async function invokeChatAbortHandler(params: { handler: GatewayRequestHandler; - context: ReturnType; + context: ChatAbortTestContext; request: { sessionKey: string; runId?: string }; client?: { connId?: string; @@ -48,8 +65,8 @@ export async function invokeChatAbortHandler(params: { scopes?: string[]; }; } | null; - respond?: ReturnType; -}) { + respond?: ChatAbortRespondMock; +}): Promise { const respond = params.respond ?? vi.fn(); await params.handler({ params: params.request, From 91d4f5cd2f432d692179516e50ee33e8ef47b82a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 18:03:18 +0000 Subject: [PATCH 061/663] test: simplify control ui http coverage --- src/gateway/control-ui.http.test.ts | 219 +++++++++++++++------------- 1 file changed, 120 insertions(+), 99 deletions(-) diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index a63bb1590e2..54cf972e79c 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -40,6 +40,25 @@ describe("handleControlUiHttpRequest", () => { expect(params.end).toHaveBeenCalledWith("Not Found"); } + function expectUnhandledRoutes(params: { + urls: string[]; + method: "GET" | "POST"; + rootPath: string; + basePath?: string; + expectationLabel: string; + }) { + for (const url of params.urls) { + const { handled, end } = runControlUiRequest({ + url, + method: params.method, + rootPath: params.rootPath, + ...(params.basePath ? { basePath: params.basePath } : {}), + }); + expect(handled, `${params.expectationLabel}: ${url}`).toBe(false); + expect(end, `${params.expectationLabel}: ${url}`).not.toHaveBeenCalled(); + } + } + function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; @@ -147,53 +166,80 @@ describe("handleControlUiHttpRequest", () => { }); }); - it("serves bootstrap config JSON", async () => { + it.each([ + { + name: "at root", + url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + expectedBasePath: "", + assistantName: ".png", + expectedAvatarUrl: "/avatar/main", + }, + { + name: "under basePath", + url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + basePath: "/openclaw", + expectedBasePath: "/openclaw", + assistantName: "Ops", + assistantAvatar: "ops.png", + expectedAvatarUrl: "/openclaw/avatar/main", + }, + ])("serves bootstrap config JSON $name", async (testCase) => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( - { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, + { url: testCase.url, method: "GET" } as IncomingMessage, res, { + ...(testCase.basePath ? { basePath: testCase.basePath } : {}), root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, - ui: { assistant: { name: ".png" } }, + ui: { + assistant: { + name: testCase.assistantName, + avatar: testCase.assistantAvatar, + }, + }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); - expect(parsed.basePath).toBe(""); - expect(parsed.assistantName).toBe(".png", - expectedAvatarUrl: "/avatar/main", - }, - { - name: "under basePath", - url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, - basePath: "/openclaw", - expectedBasePath: "/openclaw", - assistantName: "Ops", - assistantAvatar: "ops.png", - expectedAvatarUrl: "/openclaw/avatar/main", - }, - ])("serves bootstrap config JSON $name", async (testCase) => { + it("serves bootstrap config JSON", async () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( - { url: testCase.url, method: "GET" } as IncomingMessage, + { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, res, { - ...(testCase.basePath ? { basePath: testCase.basePath } : {}), root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, - ui: { - assistant: { - name: testCase.assistantName, - avatar: testCase.assistantAvatar, - }, - }, + ui: { assistant: { name: ".png" } }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); - expect(parsed.basePath).toBe(testCase.expectedBasePath); - expect(parsed.assistantName).toBe(testCase.assistantName); - expect(parsed.assistantAvatar).toBe(testCase.expectedAvatarUrl); + expect(parsed.basePath).toBe(""); + expect(parsed.assistantName).toBe("