From 8897c9d53a1d3a8531a8c57ee4143288e8abb872 Mon Sep 17 00:00:00 2001 From: Julia HeySalad Date: Mon, 23 Feb 2026 10:44:18 +0000 Subject: [PATCH 001/314] ci: install pyyaml in skills-python job --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d518a7b831..f0266c72174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,7 +335,7 @@ jobs: - name: Install Python tooling run: | python -m pip install --upgrade pip - python -m pip install pytest ruff + python -m pip install pytest ruff pyyaml - name: Lint Python skill scripts run: python -m ruff check skills From 3640484e2847fe495837e99d55092680ecf27639 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:19:27 +0100 Subject: [PATCH 002/314] fix(agents): map Moonshot developer role compatibility Co-authored-by: Sheng-Fu Chuang # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 1 + src/agents/model-compat.test.ts | 26 ++++++++++++++++++++++++++ src/agents/model-compat.ts | 6 +++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fcc92a6cb4..8a66d598c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. +- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 071f9cc9276..962724c665f 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -51,6 +51,32 @@ describe("normalizeModelCompat", () => { ).toBe(false); }); + it("forces supportsDeveloperRole off for moonshot models", () => { + const model = { + ...baseModel(), + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect( + (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, + ).toBe(false); + }); + + it("forces supportsDeveloperRole off for custom moonshot-compatible endpoints", () => { + const model = { + ...baseModel(), + provider: "custom-kimi", + baseUrl: "https://api.moonshot.cn/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect( + (normalized.compat as { supportsDeveloperRole?: boolean } | undefined)?.supportsDeveloperRole, + ).toBe(false); + }); + it("leaves non-zai models untouched", () => { const model = { ...baseModel(), diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index e7b428e8442..d97d3965103 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -7,7 +7,11 @@ function isOpenAiCompletionsModel(model: Model): model is Model<"openai-com export function normalizeModelCompat(model: Model): Model { const baseUrl = model.baseUrl ?? ""; const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai"); - if (!isZai || !isOpenAiCompletionsModel(model)) { + const isMoonshot = + model.provider === "moonshot" || + baseUrl.includes("moonshot.ai") || + baseUrl.includes("moonshot.cn"); + if ((!isZai && !isMoonshot) || !isOpenAiCompletionsModel(model)) { return model; } From 9bd04849ed05367da5f013b3bbca387e6490767d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:15:55 +0100 Subject: [PATCH 003/314] fix(agents): detect Kimi model-token-limit overflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Danilo Falcão --- CHANGELOG.md | 1 + ...-embedded-helpers.formatassistanterrortext.test.ts | 6 ++++++ .../pi-embedded-helpers.isbillingerrormessage.test.ts | 11 +++++++++++ src/agents/pi-embedded-helpers/errors.ts | 1 + 4 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a66d598c2c..9ae043cff7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. +- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 1087d1b79aa..3aedccefe79 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -29,6 +29,12 @@ describe("formatAssistantErrorText", () => { ); expect(formatAssistantErrorText(msg)).toContain("Context overflow"); }); + it("returns context overflow for Kimi 'model token limit' errors", () => { + const msg = makeAssistantError( + "error, status code: 400, message: Invalid request: Your request exceeded model token limit: 262144 (requested: 291351)", + ); + expect(formatAssistantErrorText(msg)).toContain("Context overflow"); + }); it("returns a friendly message for Anthropic role ordering", () => { const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"'); expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict"); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index d4b45f84330..ba06360ac56 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -178,6 +178,17 @@ describe("isContextOverflowError", () => { } }); + it("matches Kimi 'model token limit' context overflow errors", () => { + const samples = [ + "Invalid request: Your request exceeded model token limit: 262144 (requested: 291351)", + "error, status code: 400, message: Invalid request: Your request exceeded model token limit: 262144 (requested: 291351)", + "Your request exceeded model token limit", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + it("ignores normal conversation text mentioning context overflow", () => { // These are legitimate conversation snippets, not error messages expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 1f4204fe1d4..6d6c355d0a7 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -51,6 +51,7 @@ export function isContextOverflowError(errorMessage?: string): boolean { lower.includes("maximum context length") || lower.includes("prompt is too long") || lower.includes("exceeds model context window") || + lower.includes("model token limit") || (hasRequestSizeExceeds && hasContextWindow) || lower.includes("context overflow:") || (lower.includes("413") && lower.includes("too large")) From 15e32c7341ed5c8d4abe0c3eca38dc9f5165f56d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 05:20:48 +0100 Subject: [PATCH 004/314] fix(models): refresh Moonshot Kimi vision capabilities Co-authored-by: manikv12 --- CHANGELOG.md | 1 + ...ssing-provider-apikey-from-env-var.test.ts | 64 +++++++++++++++++++ src/agents/models-config.providers.ts | 2 +- src/agents/models-config.ts | 49 +++++++++----- src/agents/synthetic-models.ts | 2 +- src/commands/auth-choice.moonshot.test.ts | 2 + src/commands/onboard-auth.models.ts | 2 +- 7 files changed, 104 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ae043cff7b..d4e4043109f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. - Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. - Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. +- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 3f80eec7b54..c26142158e8 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -133,4 +133,68 @@ describe("models-config", () => { expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1"); }); }); + + it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => { + await withTempHome(async () => { + const prevKey = process.env.MOONSHOT_API_KEY; + process.env.MOONSHOT_API_KEY = "sk-moonshot-test"; + try { + const cfg: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + api: "openai-completions", + models: [ + { + id: "kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text"], + cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }, + ], + }, + }, + }, + }; + + await ensureOpenClawModelsJson(cfg); + + const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const raw = await fs.readFile(modelPath, "utf8"); + const parsed = JSON.parse(raw) as { + providers: Record< + string, + { + models?: Array<{ + id: string; + input?: string[]; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; + cost?: { input?: number; output?: number }; + }>; + } + >; + }; + const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5"); + expect(kimi?.input).toEqual(["text", "image"]); + expect(kimi?.reasoning).toBe(false); + expect(kimi?.contextWindow).toBe(256000); + expect(kimi?.maxTokens).toBe(8192); + // Preserve explicit user pricing overrides when refreshing capabilities. + expect(kimi?.cost?.input).toBe(123); + expect(kimi?.cost?.output).toBe(456); + } finally { + if (prevKey === undefined) { + delete process.env.MOONSHOT_API_KEY; + } else { + process.env.MOONSHOT_API_KEY = prevKey; + } + } + }); + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index b1c55b8c353..30e0326e609 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -513,7 +513,7 @@ function buildMoonshotProvider(): ProviderConfig { id: MOONSHOT_DEFAULT_MODEL_ID, name: "Kimi K2.5", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: MOONSHOT_DEFAULT_COST, contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b44c0d60b60..5ca971646e1 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -29,22 +29,41 @@ function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig) const id = (model as { id?: unknown }).id; return typeof id === "string" ? id.trim() : ""; }; - const seen = new Set(explicitModels.map(getId).filter(Boolean)); + const implicitById = new Map( + implicitModels.map((model) => [getId(model), model] as const).filter(([id]) => Boolean(id)), + ); + const seen = new Set(); - const mergedModels = [ - ...explicitModels, - ...implicitModels.filter((model) => { - const id = getId(model); - if (!id) { - return false; - } - if (seen.has(id)) { - return false; - } - seen.add(id); - return true; - }), - ]; + const mergedModels = explicitModels.map((explicitModel) => { + const id = getId(explicitModel); + if (!id) { + return explicitModel; + } + seen.add(id); + const implicitModel = implicitById.get(id); + if (!implicitModel) { + return explicitModel; + } + + // Refresh capability metadata from the implicit catalog while preserving + // user-specific fields (cost, headers, compat, etc.) on explicit entries. + return { + ...explicitModel, + input: implicitModel.input, + reasoning: implicitModel.reasoning, + contextWindow: implicitModel.contextWindow, + maxTokens: implicitModel.maxTokens, + }; + }); + + for (const implicitModel of implicitModels) { + const id = getId(implicitModel); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + mergedModels.push(implicitModel); + } return { ...implicit, diff --git a/src/agents/synthetic-models.ts b/src/agents/synthetic-models.ts index 5d820c8474b..78a0226921a 100644 --- a/src/agents/synthetic-models.ts +++ b/src/agents/synthetic-models.ts @@ -103,7 +103,7 @@ export const SYNTHETIC_MODEL_CATALOG = [ id: "hf:moonshotai/Kimi-K2.5", name: "Kimi K2.5", reasoning: true, - input: ["text"], + input: ["text", "image"], contextWindow: 256000, maxTokens: 8192, }, diff --git a/src/commands/auth-choice.moonshot.test.ts b/src/commands/auth-choice.moonshot.test.ts index 780c0e8e71b..6c9bcb45068 100644 --- a/src/commands/auth-choice.moonshot.test.ts +++ b/src/commands/auth-choice.moonshot.test.ts @@ -77,6 +77,7 @@ describe("applyAuthChoice (moonshot)", () => { "anthropic/claude-opus-4-5", ); expect(result.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); + expect(result.config.models?.providers?.moonshot?.models?.[0]?.input).toContain("image"); expect(result.agentModelOverride).toBe("moonshot/kimi-k2.5"); const parsed = await readAuthProfiles(); @@ -95,6 +96,7 @@ describe("applyAuthChoice (moonshot)", () => { "moonshot/kimi-k2.5", ); expect(result.config.models?.providers?.moonshot?.baseUrl).toBe("https://api.moonshot.cn/v1"); + expect(result.config.models?.providers?.moonshot?.models?.[0]?.input).toContain("image"); expect(result.agentModelOverride).toBeUndefined(); const parsed = await readAuthProfiles(); diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index fa97cc7b96d..167cde6809d 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -130,7 +130,7 @@ export function buildMoonshotModelDefinition(): ModelDefinitionConfig { id: MOONSHOT_DEFAULT_MODEL_ID, name: "Kimi K2.5", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: MOONSHOT_DEFAULT_COST, contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, From 9757d2bb646d1a1cd9ea9dd041054af6988372d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 06:08:38 +0100 Subject: [PATCH 005/314] fix(agents): normalize strict openai-compatible turn ordering Co-authored-by: liuwenyong1985 <48443240+liuwenyong1985@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/transcript-policy.test.ts | 18 ++++++++++++++++++ src/agents/transcript-policy.ts | 7 ++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e4043109f..4191591ad81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. - Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. - Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. +- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 1da43856128..4ef038c81b7 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -43,4 +43,22 @@ describe("resolveTranscriptPolicy", () => { expect(policy.sanitizeToolCallIds).toBe(false); expect(policy.toolCallIdMode).toBeUndefined(); }); + + it("enables user-turn merge for strict OpenAI-compatible providers", () => { + const policy = resolveTranscriptPolicy({ + provider: "moonshot", + modelId: "kimi-k2.5", + modelApi: "openai-completions", + }); + expect(policy.validateAnthropicTurns).toBe(true); + }); + + it("keeps OpenRouter on its existing turn-validation path", () => { + const policy = resolveTranscriptPolicy({ + provider: "openrouter", + modelId: "openai/gpt-4.1", + modelApi: "openai-completions", + }); + expect(policy.validateAnthropicTurns).toBe(false); + }); }); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index a94d7eb2c9f..7f7e08d6386 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -38,6 +38,7 @@ const OPENAI_MODEL_APIS = new Set([ "openai-codex-responses", ]); const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); +const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -84,6 +85,10 @@ export function resolveTranscriptPolicy(params: { const isGoogle = isGoogleModelApi(params.modelApi); const isAnthropic = isAnthropicApi(params.modelApi, provider); const isOpenAi = isOpenAiProvider(provider) || (!provider && isOpenAiApi(params.modelApi)); + const isStrictOpenAiCompatible = + params.modelApi === "openai-completions" && + !isOpenAi && + !OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS.has(provider); const isMistral = isMistralModel({ provider, modelId }); const isOpenRouterGemini = (provider === "openrouter" || provider === "opencode") && @@ -118,7 +123,7 @@ export function resolveTranscriptPolicy(params: { dropThinkingBlocks, applyGoogleTurnOrdering: !isOpenAi && isGoogle, validateGeminiTurns: !isOpenAi && isGoogle, - validateAnthropicTurns: !isOpenAi && isAnthropic, + validateAnthropicTurns: !isOpenAi && (isAnthropic || isStrictOpenAiCompatible), allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic), }; } From be422a9d18ed6cac29d26104c31f1e862ded80c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 12:50:08 +0000 Subject: [PATCH 006/314] test: merge model picker tests into native command suite --- ...uick-model-picker-grouped-by-model.test.ts | 135 ------------------ ...targets-active-session-native-stop.test.ts | 119 +++++++++++++++ 2 files changed, 119 insertions(+), 135 deletions(-) delete mode 100644 src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts deleted file mode 100644 index 3cf248fe871..00000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { normalizeTestText } from "../../test/helpers/normalize-text.js"; -import { loadSessionStore } from "../config/sessions.js"; -import { - installTriggerHandlingE2eTestHooks, - makeCfg, - withTempHome, -} from "./reply.triggers.trigger-handling.test-harness.js"; - -let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); -}); - -installTriggerHandlingE2eTestHooks(); - -const DEFAULT_SESSION_KEY = "telegram:slash:111"; - -function requireSessionStorePath(cfg: { session?: { store?: string } }): string { - const storePath = cfg.session?.store; - if (!storePath) { - throw new Error("expected session store path"); - } - return storePath; -} - -function makeTelegramModelCommand(body: string, sessionKey = DEFAULT_SESSION_KEY) { - return { - Body: body, - From: "telegram:111", - To: "telegram:111", - ChatType: "direct" as const, - Provider: "telegram" as const, - Surface: "telegram" as const, - SessionKey: sessionKey, - CommandAuthorized: true, - }; -} - -function firstReplyText(reply: Awaited>) { - return Array.isArray(reply) ? (reply[0]?.text ?? "") : (reply?.text ?? ""); -} - -async function runModelCommand(home: string, body: string, sessionKey = DEFAULT_SESSION_KEY) { - const cfg = makeCfg(home); - const res = await getReplyFromConfig(makeTelegramModelCommand(body, sessionKey), {}, cfg); - const text = firstReplyText(res); - return { - cfg, - sessionKey, - text, - normalized: normalizeTestText(text), - }; -} - -describe("trigger handling", () => { - it("shows a /model summary and points to /models", async () => { - await withTempHome(async (home) => { - const { normalized } = await runModelCommand(home, "/model"); - - expect(normalized).toContain("Current: anthropic/claude-opus-4-5"); - expect(normalized).toContain("/model to switch"); - expect(normalized).toContain("Tap below to browse models"); - expect(normalized).toContain("/model status for details"); - expect(normalized).not.toContain("reasoning"); - expect(normalized).not.toContain("image"); - }); - }); - - it("aliases /model list to /models", async () => { - await withTempHome(async (home) => { - const { normalized } = await runModelCommand(home, "/model list"); - - expect(normalized).toContain("Providers:"); - expect(normalized).toContain("Use: /models "); - expect(normalized).toContain("Switch: /model "); - }); - }); - - it("selects the exact provider/model pair for openrouter", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand( - home, - "/model openrouter/anthropic/claude-opus-4-5", - ); - - expect(normalized).toContain("Model set to openrouter/anthropic/claude-opus-4-5"); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBe("openrouter"); - expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5"); - }); - }); - - it("rejects invalid /model <#> selections", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand(home, "/model 99"); - - expect(normalized).toContain("Numeric model selection is not supported in chat."); - expect(normalized).toContain("Browse: /models or /models "); - expect(normalized).toContain("Switch: /model "); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBeUndefined(); - expect(store[sessionKey]?.modelOverride).toBeUndefined(); - }); - }); - - it("resets to the default model via /model ", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand( - home, - "/model anthropic/claude-opus-4-5", - ); - - expect(normalized).toContain("Model reset to default (anthropic/claude-opus-4-5)"); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBeUndefined(); - expect(store[sessionKey]?.modelOverride).toBeUndefined(); - }); - }); - - it("selects a model via /model ", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand(home, "/model openai/gpt-5.2"); - - expect(normalized).toContain("Model set to openai/gpt-5.2"); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBe("openai"); - expect(store[sessionKey]?.modelOverride).toBe("gpt-5.2"); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts index 0d5c6e2db81..dff015c0057 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { @@ -30,7 +31,125 @@ afterAll(() => { installTriggerHandlingE2eTestHooks(); +const DEFAULT_SESSION_KEY = "telegram:slash:111"; + +function requireSessionStorePath(cfg: { session?: { store?: string } }): string { + const storePath = cfg.session?.store; + if (!storePath) { + throw new Error("expected session store path"); + } + return storePath; +} + +function makeTelegramModelCommand(body: string, sessionKey = DEFAULT_SESSION_KEY) { + return { + Body: body, + From: "telegram:111", + To: "telegram:111", + ChatType: "direct" as const, + Provider: "telegram" as const, + Surface: "telegram" as const, + SessionKey: sessionKey, + CommandAuthorized: true, + }; +} + +function firstReplyText(reply: Awaited>) { + return Array.isArray(reply) ? (reply[0]?.text ?? "") : (reply?.text ?? ""); +} + +async function runModelCommand(home: string, body: string, sessionKey = DEFAULT_SESSION_KEY) { + const cfg = makeCfg(home); + const res = await getReplyFromConfig(makeTelegramModelCommand(body, sessionKey), {}, cfg); + const text = firstReplyText(res); + return { + cfg, + sessionKey, + text, + normalized: normalizeTestText(text), + }; +} + describe("trigger handling", () => { + it("shows a /model summary and points to /models", async () => { + await withTempHome(async (home) => { + const { normalized } = await runModelCommand(home, "/model"); + + expect(normalized).toContain("Current: anthropic/claude-opus-4-5"); + expect(normalized).toContain("/model to switch"); + expect(normalized).toContain("Tap below to browse models"); + expect(normalized).toContain("/model status for details"); + expect(normalized).not.toContain("reasoning"); + expect(normalized).not.toContain("image"); + }); + }); + + it("aliases /model list to /models", async () => { + await withTempHome(async (home) => { + const { normalized } = await runModelCommand(home, "/model list"); + + expect(normalized).toContain("Providers:"); + expect(normalized).toContain("Use: /models "); + expect(normalized).toContain("Switch: /model "); + }); + }); + + it("selects the exact provider/model pair for openrouter", async () => { + await withTempHome(async (home) => { + const { cfg, sessionKey, normalized } = await runModelCommand( + home, + "/model openrouter/anthropic/claude-opus-4-5", + ); + + expect(normalized).toContain("Model set to openrouter/anthropic/claude-opus-4-5"); + + const store = loadSessionStore(requireSessionStorePath(cfg)); + expect(store[sessionKey]?.providerOverride).toBe("openrouter"); + expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5"); + }); + }); + + it("rejects invalid /model <#> selections", async () => { + await withTempHome(async (home) => { + const { cfg, sessionKey, normalized } = await runModelCommand(home, "/model 99"); + + expect(normalized).toContain("Numeric model selection is not supported in chat."); + expect(normalized).toContain("Browse: /models or /models "); + expect(normalized).toContain("Switch: /model "); + + const store = loadSessionStore(requireSessionStorePath(cfg)); + expect(store[sessionKey]?.providerOverride).toBeUndefined(); + expect(store[sessionKey]?.modelOverride).toBeUndefined(); + }); + }); + + it("resets to the default model via /model ", async () => { + await withTempHome(async (home) => { + const { cfg, sessionKey, normalized } = await runModelCommand( + home, + "/model anthropic/claude-opus-4-5", + ); + + expect(normalized).toContain("Model reset to default (anthropic/claude-opus-4-5)"); + + const store = loadSessionStore(requireSessionStorePath(cfg)); + expect(store[sessionKey]?.providerOverride).toBeUndefined(); + expect(store[sessionKey]?.modelOverride).toBeUndefined(); + }); + }); + + it("selects a model via /model ", async () => { + await withTempHome(async (home) => { + const { cfg, sessionKey, normalized } = await runModelCommand(home, "/model openai/gpt-5.2"); + + expect(normalized).toContain("Model set to openai/gpt-5.2"); + + const store = loadSessionStore(requireSessionStorePath(cfg)); + expect(store[sessionKey]?.providerOverride).toBe("openai"); + expect(store[sessionKey]?.modelOverride).toBe("gpt-5.2"); + }); + }); + it("targets the active session for native /stop", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); From b11ff9f7dd28b71bea5619a97c999bbe6ba3fd34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 12:54:52 +0000 Subject: [PATCH 007/314] test: collapse directive behavior shards --- ...ccepts-thinking-xhigh-codex-models.test.ts | 18 --- ...ng-mixed-messages-acks-immediately.test.ts | 111 +++++++++++++--- ...ists-allowlisted-models-model-list.test.ts | 12 ++ ...l-verbose-during-flight-run-toggle.test.ts | 125 ------------------ 4 files changed, 101 insertions(+), 165 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts index 75eb23b0dd1..40a145220c1 100644 --- a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts @@ -187,22 +187,4 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("shows current think level when /think has no argument", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig( - home, - { model: "anthropic/claude-opus-4-5", thinkingDefault: "high" }, - { session: { store: sessionStorePath(home) } }, - ), - ); - - const text = replyText(res); - expect(text).toContain("Current thinking level: high"); - expect(text).toContain("Options: off, minimal, low, medium, high."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts index 08c7f493f05..78a7fb2792f 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts @@ -1,8 +1,9 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; import { describe, expect, it, vi } from "vitest"; -import { loadSessionStore } from "../config/sessions.js"; +import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js"; import { installDirectiveBehaviorE2EHooks, + makeEmbeddedTextResult, makeWhatsAppDirectiveConfig, replyText, replyTexts, @@ -12,29 +13,20 @@ import { } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; -async function runThinkDirectiveAndGetText( - home: string, - options: { thinkingDefault?: "high" } = {}, -): Promise { +async function runThinkDirectiveAndGetText(home: string): Promise { const res = await getReplyFromConfig( { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5", - ...(options.thinkingDefault ? { thinkingDefault: options.thinkingDefault } : {}), + thinkingDefault: "high", }), ); return replyText(res); } function mockEmbeddedResponse(text: string) { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text)); } async function runInlineReasoningMessage(params: { @@ -67,6 +59,62 @@ async function runInlineReasoningMessage(params: { ); } +function makeRunConfig(home: string, storePath: string) { + return makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { session: { store: storePath } }, + ); +} + +async function runInFlightVerboseToggleCase(params: { + home: string; + shouldEmitBefore: boolean; + toggledVerboseLevel: "on" | "off"; + seedVerboseOn?: boolean; +}) { + const storePath = sessionStorePath(params.home); + const ctx = { + Body: "please do the thing", + From: "+1004", + To: "+2000", + }; + const sessionKey = resolveSessionKey( + "per-sender", + { From: ctx.From, To: ctx.To, Body: ctx.Body }, + "main", + ); + + vi.mocked(runEmbeddedPiAgent).mockImplementation(async (agentParams) => { + const shouldEmit = agentParams.shouldEmitToolResult; + expect(shouldEmit?.()).toBe(params.shouldEmitBefore); + const store = loadSessionStore(storePath); + const entry = store[sessionKey] ?? { + sessionId: "s", + updatedAt: Date.now(), + }; + store[sessionKey] = { + ...entry, + verboseLevel: params.toggledVerboseLevel, + updatedAt: Date.now(), + }; + await saveSessionStore(storePath, store); + expect(shouldEmit?.()).toBe(!params.shouldEmitBefore); + return makeEmbeddedTextResult("done"); + }); + + if (params.seedVerboseOn) { + await getReplyFromConfig( + { Body: "/verbose on", From: ctx.From, To: ctx.To, CommandAuthorized: true }, + {}, + makeRunConfig(params.home, storePath), + ); + } + + const res = await getReplyFromConfig(ctx, {}, makeRunConfig(params.home, storePath)); + return { res }; +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); @@ -152,20 +200,39 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("updates tool verbose during an in-flight run (toggle on)", async () => { + await withTempHome(async (home) => { + const { res } = await runInFlightVerboseToggleCase({ + home, + shouldEmitBefore: false, + toggledVerboseLevel: "on", + }); + + const texts = replyTexts(res); + expect(texts).toContain("done"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); + }); + it("updates tool verbose during an in-flight run (toggle off)", async () => { + await withTempHome(async (home) => { + const { res } = await runInFlightVerboseToggleCase({ + home, + shouldEmitBefore: true, + toggledVerboseLevel: "off", + seedVerboseOn: true, + }); + + const texts = replyTexts(res); + expect(texts).toContain("done"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); + }); it("shows current think level when /think has no argument", async () => { await withTempHome(async (home) => { - const text = await runThinkDirectiveAndGetText(home, { thinkingDefault: "high" }); + const text = await runThinkDirectiveAndGetText(home); expect(text).toContain("Current thinking level: high"); expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("shows off when /think has no argument and no default set", async () => { - await withTempHome(async (home) => { - const text = await runThinkDirectiveAndGetText(home); - expect(text).toContain("Current thinking level: off"); - expect(text).toContain("Options: off, minimal, low, medium, high."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts index 5ad163dac5d..759136fc65d 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts @@ -37,6 +37,18 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("lists allowlisted models on /model status", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model status", { + includeSessionStore: false, + }); + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(text).not.toContain("claude-sonnet-4-1"); + expect(text).toContain("auth:"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("includes catalog providers when no allowlist is set", async () => { await withTempHome(async (home) => { vi.mocked(loadModelCatalog).mockResolvedValue([ diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts deleted file mode 100644 index 9081566adea..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it, vi } from "vitest"; -import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js"; -import { - installDirectiveBehaviorE2EHooks, - makeEmbeddedTextResult, - makeWhatsAppDirectiveConfig, - replyTexts, - runEmbeddedPiAgent, - sessionStorePath, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; -import { getReplyFromConfig } from "./reply.js"; - -function makeRunConfig(home: string, storePath: string) { - return makeWhatsAppDirectiveConfig( - home, - { model: "anthropic/claude-opus-4-5" }, - { session: { store: storePath } }, - ); -} - -async function runInFlightVerboseToggleCase(params: { - home: string; - shouldEmitBefore: boolean; - toggledVerboseLevel: "on" | "off"; - seedVerboseOn?: boolean; -}) { - const storePath = sessionStorePath(params.home); - const ctx = { - Body: "please do the thing", - From: "+1004", - To: "+2000", - }; - const sessionKey = resolveSessionKey( - "per-sender", - { From: ctx.From, To: ctx.To, Body: ctx.Body }, - "main", - ); - - vi.mocked(runEmbeddedPiAgent).mockImplementation(async (agentParams) => { - const shouldEmit = agentParams.shouldEmitToolResult; - expect(shouldEmit?.()).toBe(params.shouldEmitBefore); - const store = loadSessionStore(storePath); - const entry = store[sessionKey] ?? { - sessionId: "s", - updatedAt: Date.now(), - }; - store[sessionKey] = { - ...entry, - verboseLevel: params.toggledVerboseLevel, - updatedAt: Date.now(), - }; - await saveSessionStore(storePath, store); - expect(shouldEmit?.()).toBe(!params.shouldEmitBefore); - return makeEmbeddedTextResult("done"); - }); - - if (params.seedVerboseOn) { - await getReplyFromConfig( - { Body: "/verbose on", From: ctx.From, To: ctx.To, CommandAuthorized: true }, - {}, - makeRunConfig(params.home, storePath), - ); - } - - const res = await getReplyFromConfig(ctx, {}, makeRunConfig(params.home, storePath)); - return { res }; -} - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("updates tool verbose during an in-flight run (toggle on)", async () => { - await withTempHome(async (home) => { - const { res } = await runInFlightVerboseToggleCase({ - home, - shouldEmitBefore: false, - toggledVerboseLevel: "on", - }); - - const texts = replyTexts(res); - expect(texts).toContain("done"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - }); - }); - it("updates tool verbose during an in-flight run (toggle off)", async () => { - await withTempHome(async (home) => { - const { res } = await runInFlightVerboseToggleCase({ - home, - shouldEmitBefore: true, - toggledVerboseLevel: "off", - seedVerboseOn: true, - }); - - const texts = replyTexts(res); - expect(texts).toContain("done"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - }); - }); - it("shows summary on /model", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model", { includeSessionStore: false }); - expect(text).toContain("Current: anthropic/claude-opus-4-5"); - expect(text).toContain("Switch: /model "); - expect(text).toContain("Browse: /models (providers) or /models (models)"); - expect(text).toContain("More: /model status"); - expect(text).not.toContain("openai/gpt-4.1-mini"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("lists allowlisted models on /model status", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model status", { - includeSessionStore: false, - }); - expect(text).toContain("anthropic/claude-opus-4-5"); - expect(text).toContain("openai/gpt-4.1-mini"); - expect(text).not.toContain("claude-sonnet-4-1"); - expect(text).toContain("auth:"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); From fbdb1b3e733ff6350079c1220ce96d48e31e2ad6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 12:57:39 +0000 Subject: [PATCH 008/314] test: merge elevated status directive shards --- ...urrent-elevated-level-as-off-after.test.ts | 77 ------------------- ...rrent-verbose-level-verbose-has-no.test.ts | 48 ++++++++++++ 2 files changed, 48 insertions(+), 77 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts deleted file mode 100644 index 2c38466367c..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadSessionStore } from "../config/sessions.js"; -import { - AUTHORIZED_WHATSAPP_COMMAND, - installDirectiveBehaviorE2EHooks, - makeElevatedDirectiveConfig, - replyText, - makeRestrictedElevatedDisabledConfig, - runEmbeddedPiAgent, - sessionStorePath, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { getReplyFromConfig } from "./reply.js"; - -async function runAuthorizedCommand(home: string, body: string) { - return getReplyFromConfig( - { - ...AUTHORIZED_WHATSAPP_COMMAND, - Body: body, - }, - {}, - makeElevatedDirectiveConfig(home), - ); -} - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("shows current elevated level as off after toggling it off", async () => { - await withTempHome(async (home) => { - await runAuthorizedCommand(home, "/elevated off"); - const res = await runAuthorizedCommand(home, "/elevated"); - const text = replyText(res); - expect(text).toContain("Current elevated level: off"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("can toggle elevated off then back on (status reflects on)", async () => { - await withTempHome(async (home) => { - const storePath = sessionStorePath(home); - await runAuthorizedCommand(home, "/elevated off"); - await runAuthorizedCommand(home, "/elevated on"); - const res = await runAuthorizedCommand(home, "/status"); - const text = replyText(res); - const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️")); - expect(optionsLine).toBeTruthy(); - expect(optionsLine).toContain("elevated"); - - const store = loadSessionStore(storePath); - expect(store["agent:main:main"]?.elevatedLevel).toBe("on"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("rejects per-agent elevated when disabled", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - SessionKey: "agent:restricted:main", - CommandAuthorized: true, - }, - {}, - makeRestrictedElevatedDisabledConfig(home) as unknown as OpenClawConfig, - ); - - const text = replyText(res); - expect(text).toContain("agents.list[].tools.elevated.enabled"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 02406d22fd0..3ca0bd63f8f 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -1,11 +1,13 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { AUTHORIZED_WHATSAPP_COMMAND, assertElevatedOffStatusReply, installDirectiveBehaviorE2EHooks, makeElevatedDirectiveConfig, + makeRestrictedElevatedDisabledConfig, makeWhatsAppDirectiveConfig, replyText, runEmbeddedPiAgent, @@ -111,6 +113,52 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("shows current elevated level as off after toggling it off", async () => { + await withTempHome(async (home) => { + await runElevatedCommand(home, "/elevated off"); + const res = await runElevatedCommand(home, "/elevated"); + const text = replyText(res); + expect(text).toContain("Current elevated level: off"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("can toggle elevated off then back on (status reflects on)", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + await runElevatedCommand(home, "/elevated off"); + await runElevatedCommand(home, "/elevated on"); + const res = await runElevatedCommand(home, "/status"); + const text = replyText(res); + const optionsLine = text?.split("\n").find((line) => line.trim().startsWith("⚙️")); + expect(optionsLine).toBeTruthy(); + expect(optionsLine).toContain("elevated"); + + const store = loadSessionStore(storePath); + expect(store["agent:main:main"]?.elevatedLevel).toBe("on"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("rejects per-agent elevated when disabled", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + SessionKey: "agent:restricted:main", + CommandAuthorized: true, + }, + {}, + makeRestrictedElevatedDisabledConfig(home) as unknown as OpenClawConfig, + ); + + const text = replyText(res); + expect(text).toContain("agents.list[].tools.elevated.enabled"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("strips inline elevated directives from the user text (does not persist session override)", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ From 706c9ec729c0c75a2b7c84565583615dd359790b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:02:56 +0000 Subject: [PATCH 009/314] test: consolidate directive behavior suites --- ...nk-low-reasoning-capable-models-no.test.ts | 94 ++++++++++ ...es-inline-model-uses-default-model.test.ts | 111 ------------ ...atus-alongside-directive-only-acks.test.ts | 163 ------------------ ...rrent-verbose-level-verbose-has-no.test.ts | 74 ++++++++ 4 files changed, 168 insertions(+), 274 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts delete mode 100644 src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 4696de517ce..e517c244e39 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -15,6 +15,16 @@ import { } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +function makeDefaultModelConfig(home: string) { + return makeWhatsAppDirectiveConfig(home, { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }); +} + async function runReplyToCurrentCase(home: string, text: string) { vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text)); @@ -74,6 +84,90 @@ describe("directive behavior", () => { expectedLevel: "off", }); }); + it("ignores inline /model and uses the default model", async () => { + await withTempHome(async (home) => { + mockEmbeddedTextResult("done"); + + const res = await getReplyFromConfig( + { + Body: "please sync /model openai/gpt-4.1-mini now", + From: "+1004", + To: "+2000", + }, + {}, + makeDefaultModelConfig(home), + ); + + const texts = replyTexts(res); + expect(texts).toContain("done"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.provider).toBe("anthropic"); + expect(call?.model).toBe("claude-opus-4-5"); + }); + }); + it("defaults thinking to low for reasoning-capable models during normal replies", async () => { + await withTempHome(async (home) => { + mockEmbeddedTextResult("done"); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + reasoning: true, + }, + ]); + + await getReplyFromConfig( + { + Body: "hello", + From: "+1004", + To: "+2000", + }, + {}, + makeWhatsAppDirectiveConfig(home, { model: { primary: "anthropic/claude-opus-4-5" } }), + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.thinkLevel).toBe("low"); + }); + }); + it("passes elevated defaults when sender is approved", async () => { + await withTempHome(async (home) => { + mockEmbeddedTextResult("done"); + + await getReplyFromConfig( + { + Body: "hello", + From: "+1004", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1004", + }, + {}, + makeWhatsAppDirectiveConfig( + home, + { model: { primary: "anthropic/claude-opus-4-5" } }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1004"] }, + }, + }, + }, + ), + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.bashElevated).toEqual({ + enabled: true, + allowed: true, + defaultLevel: "on", + }); + }); + }); it("persists /reasoning off on discord even when model defaults reasoning on", async () => { await withTempHome(async (home) => { const storePath = sessionStorePath(home); diff --git a/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts b/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts deleted file mode 100644 index 410a5b62fdc..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it, vi } from "vitest"; -import { - installDirectiveBehaviorE2EHooks, - loadModelCatalog, - makeWhatsAppDirectiveConfig, - mockEmbeddedTextResult, - replyTexts, - runEmbeddedPiAgent, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { getReplyFromConfig } from "./reply.js"; - -function makeDefaultModelConfig(home: string) { - return makeWhatsAppDirectiveConfig(home, { - model: { primary: "anthropic/claude-opus-4-5" }, - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }); -} - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("ignores inline /model and uses the default model", async () => { - await withTempHome(async (home) => { - mockEmbeddedTextResult("done"); - - const res = await getReplyFromConfig( - { - Body: "please sync /model openai/gpt-4.1-mini now", - From: "+1004", - To: "+2000", - }, - {}, - makeDefaultModelConfig(home), - ); - - const texts = replyTexts(res); - expect(texts).toContain("done"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.provider).toBe("anthropic"); - expect(call?.model).toBe("claude-opus-4-5"); - }); - }); - it("defaults thinking to low for reasoning-capable models", async () => { - await withTempHome(async (home) => { - mockEmbeddedTextResult("done"); - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - id: "claude-opus-4-5", - name: "Opus 4.5", - provider: "anthropic", - reasoning: true, - }, - ]); - - await getReplyFromConfig( - { - Body: "hello", - From: "+1004", - To: "+2000", - }, - {}, - makeWhatsAppDirectiveConfig(home, { model: { primary: "anthropic/claude-opus-4-5" } }), - ); - - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.thinkLevel).toBe("low"); - }); - }); - it("passes elevated defaults when sender is approved", async () => { - await withTempHome(async (home) => { - mockEmbeddedTextResult("done"); - - await getReplyFromConfig( - { - Body: "hello", - From: "+1004", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1004", - }, - {}, - makeWhatsAppDirectiveConfig( - home, - { model: { primary: "anthropic/claude-opus-4-5" } }, - { - tools: { - elevated: { - allowFrom: { whatsapp: ["+1004"] }, - }, - }, - }, - ), - ); - - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; - expect(call?.bashElevated).toEqual({ - enabled: true, - allowed: true, - defaultLevel: "on", - }); - }); - }); -}); diff --git a/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts b/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts deleted file mode 100644 index 8af2f80e3b5..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadSessionStore } from "../config/sessions.js"; -import { - assertElevatedOffStatusReply, - installDirectiveBehaviorE2EHooks, - makeRestrictedElevatedDisabledConfig, - runEmbeddedPiAgent, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { getReplyFromConfig } from "./reply.js"; - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - function extractReplyText(res: Awaited>): string { - return (Array.isArray(res) ? res[0]?.text : res?.text) ?? ""; - } - - function makeQueueDirectiveConfig(home: string, storePath: string) { - return { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: storePath }, - } as unknown as OpenClawConfig; - } - - async function runQueueDirective(params: { home: string; storePath: string; body: string }) { - return await getReplyFromConfig( - { Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeQueueDirectiveConfig(params.home, params.storePath), - ); - } - - it("returns status alongside directive-only acks", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await getReplyFromConfig( - { - Body: "/elevated off\n/status", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - CommandAuthorized: true, - }, - {}, - { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - }, - }, - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - session: { store: storePath }, - }, - ); - - const text = extractReplyText(res); - expect(text).toContain("Session: agent:main:main"); - assertElevatedOffStatusReply(text); - - const store = loadSessionStore(storePath); - expect(store["agent:main:main"]?.elevatedLevel).toBe("off"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("shows elevated off in status when per-agent elevated is disabled", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - SessionKey: "agent:restricted:main", - CommandAuthorized: true, - }, - {}, - makeRestrictedElevatedDisabledConfig(home) as unknown as OpenClawConfig, - ); - - const text = extractReplyText(res); - expect(text).not.toContain("elevated"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("acks queue directive and persists override", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await runQueueDirective({ - home, - storePath, - body: "/queue interrupt", - }); - - const text = extractReplyText(res); - expect(text).toMatch(/^⚙️ Queue mode set to interrupt\./); - const store = loadSessionStore(storePath); - const entry = Object.values(store)[0]; - expect(entry?.queueMode).toBe("interrupt"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("persists queue options when directive is standalone", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await runQueueDirective({ - home, - storePath, - body: "/queue collect debounce:2s cap:5 drop:old", - }); - - const text = extractReplyText(res); - expect(text).toMatch(/^⚙️ Queue mode set to collect\./); - expect(text).toMatch(/Queue debounce set to 2000ms/); - expect(text).toMatch(/Queue cap set to 5/); - expect(text).toMatch(/Queue drop set to old/); - const store = loadSessionStore(storePath); - const entry = Object.values(store)[0]; - expect(entry?.queueMode).toBe("collect"); - expect(entry?.queueDebounceMs).toBe(2000); - expect(entry?.queueCap).toBe(5); - expect(entry?.queueDrop).toBe("old"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("resets queue mode to default", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - await runQueueDirective({ home, storePath, body: "/queue interrupt" }); - const res = await runQueueDirective({ home, storePath, body: "/queue reset" }); - const text = extractReplyText(res); - expect(text).toMatch(/^⚙️ Queue mode reset to default\./); - const store = loadSessionStore(storePath); - const entry = Object.values(store)[0]; - expect(entry?.queueMode).toBeUndefined(); - expect(entry?.queueDebounceMs).toBeUndefined(); - expect(entry?.queueCap).toBeUndefined(); - expect(entry?.queueDrop).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 3ca0bd63f8f..40cb72b48a0 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -50,6 +50,10 @@ async function runElevatedCommand(home: string, body: string) { ); } +async function runQueueDirective(home: string, body: string) { + return runCommand(home, body); +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); @@ -106,6 +110,7 @@ describe("directive behavior", () => { const storePath = sessionStorePath(home); const res = await runElevatedCommand(home, "/elevated off\n/status"); const text = replyText(res); + expect(text).toContain("Session: agent:main:main"); assertElevatedOffStatusReply(text); const store = loadSessionStore(storePath); @@ -159,6 +164,75 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("shows elevated off in status when per-agent elevated is disabled", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + SessionKey: "agent:restricted:main", + CommandAuthorized: true, + }, + {}, + makeRestrictedElevatedDisabledConfig(home) as unknown as OpenClawConfig, + ); + + const text = replyText(res); + expect(text).not.toContain("elevated"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("acks queue directive and persists override", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + + const text = await runQueueDirective(home, "/queue interrupt"); + + expect(text).toMatch(/^⚙️ Queue mode set to interrupt\./); + const store = loadSessionStore(storePath); + const entry = Object.values(store)[0]; + expect(entry?.queueMode).toBe("interrupt"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("persists queue options when directive is standalone", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + + const text = await runQueueDirective(home, "/queue collect debounce:2s cap:5 drop:old"); + + expect(text).toMatch(/^⚙️ Queue mode set to collect\./); + expect(text).toMatch(/Queue debounce set to 2000ms/); + expect(text).toMatch(/Queue cap set to 5/); + expect(text).toMatch(/Queue drop set to old/); + const store = loadSessionStore(storePath); + const entry = Object.values(store)[0]; + expect(entry?.queueMode).toBe("collect"); + expect(entry?.queueDebounceMs).toBe(2000); + expect(entry?.queueCap).toBe(5); + expect(entry?.queueDrop).toBe("old"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("resets queue mode to default", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + + await runQueueDirective(home, "/queue interrupt"); + const text = await runQueueDirective(home, "/queue reset"); + expect(text).toMatch(/^⚙️ Queue mode reset to default\./); + const store = loadSessionStore(storePath); + const entry = Object.values(store)[0]; + expect(entry?.queueMode).toBeUndefined(); + expect(entry?.queueDebounceMs).toBeUndefined(); + expect(entry?.queueCap).toBeUndefined(); + expect(entry?.queueDrop).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("strips inline elevated directives from the user text (does not persist session override)", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ From e048ed1efdd2014f7fb58fb60485faeb8aa97cc7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:05:39 +0000 Subject: [PATCH 010/314] test: merge elevated allowlist directive shard --- ...er-agent-allowlist-addition-global.test.ts | 160 ------------------ ...rrent-verbose-level-verbose-has-no.test.ts | 145 ++++++++++++++++ 2 files changed, 145 insertions(+), 160 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts deleted file mode 100644 index 767cbf476a5..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it } from "vitest"; -import { - installDirectiveBehaviorE2EHooks, - makeWhatsAppDirectiveConfig, - replyText, - runEmbeddedPiAgent, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { getReplyFromConfig } from "./reply.js"; - -function makeWorkElevatedAllowlistConfig(home: string) { - const base = makeWhatsAppDirectiveConfig( - home, - { - model: "anthropic/claude-opus-4-5", - }, - { - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222", "+1333"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } }, - }, - ); - return { - ...base, - agents: { - ...base.agents, - list: [ - { - id: "work", - tools: { - elevated: { - allowFrom: { whatsapp: ["+1333"] }, - }, - }, - }, - ], - }, - }; -} - -function makeElevatedDirectiveConfig( - home: string, - defaults: Record = {}, - extra: Record = {}, -) { - return makeWhatsAppDirectiveConfig( - home, - { - model: "anthropic/claude-opus-4-5", - ...defaults, - }, - { - tools: { - elevated: { - allowFrom: { whatsapp: ["+1222"] }, - }, - }, - channels: { whatsapp: { allowFrom: ["+1222"] } }, - ...extra, - }, - ); -} - -function makeCommandMessage(body: string, from = "+1222") { - return { - Body: body, - From: from, - To: from, - Provider: "whatsapp", - SenderE164: from, - CommandAuthorized: true, - } as const; -} - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("requires per-agent allowlist in addition to global", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - SenderE164: "+1222", - SessionKey: "agent:work:main", - CommandAuthorized: true, - }, - {}, - makeWorkElevatedAllowlistConfig(home), - ); - - const text = replyText(res); - expect(text).toContain("agents.list[].tools.elevated.allowFrom.whatsapp"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("allows elevated when both global and per-agent allowlists match", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - ...makeCommandMessage("/elevated on", "+1333"), - SessionKey: "agent:work:main", - }, - {}, - makeWorkElevatedAllowlistConfig(home), - ); - - const text = replyText(res); - expect(text).toContain("Elevated mode set to ask"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("warns when elevated is used in direct runtime", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - makeCommandMessage("/elevated off"), - {}, - makeElevatedDirectiveConfig(home, { sandbox: { mode: "off" } }), - ); - - const text = replyText(res); - expect(text).toContain("Elevated mode disabled."); - expect(text).toContain("Runtime is direct; sandboxing does not apply."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("rejects invalid elevated level", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - makeCommandMessage("/elevated maybe"), - {}, - makeElevatedDirectiveConfig(home), - ); - - const text = replyText(res); - expect(text).toContain("Unrecognized elevated level"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("handles multiple directives in a single message", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - makeCommandMessage("/elevated off\n/verbose on"), - {}, - makeElevatedDirectiveConfig(home), - ); - - const text = replyText(res); - expect(text).toContain("Elevated mode disabled."); - expect(text).toContain("Verbose logging enabled."); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 40cb72b48a0..c322e259c68 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -54,6 +54,73 @@ async function runQueueDirective(home: string, body: string) { return runCommand(home, body); } +function makeWorkElevatedAllowlistConfig(home: string) { + const base = makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222", "+1333"] }, + }, + }, + channels: { whatsapp: { allowFrom: ["+1222", "+1333"] } }, + }, + ); + return { + ...base, + agents: { + ...base.agents, + list: [ + { + id: "work", + tools: { + elevated: { + allowFrom: { whatsapp: ["+1333"] }, + }, + }, + }, + ], + }, + }; +} + +function makeAllowlistedElevatedConfig( + home: string, + defaults: Record = {}, + extra: Record = {}, +) { + return makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + ...defaults, + }, + { + tools: { + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + channels: { whatsapp: { allowFrom: ["+1222"] } }, + ...extra, + }, + ); +} + +function makeCommandMessage(body: string, from = "+1222") { + return { + Body: body, + From: from, + To: from, + Provider: "whatsapp", + SenderE164: from, + CommandAuthorized: true, + } as const; +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); @@ -164,6 +231,84 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("requires per-agent allowlist in addition to global", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + SessionKey: "agent:work:main", + CommandAuthorized: true, + }, + {}, + makeWorkElevatedAllowlistConfig(home), + ); + + const text = replyText(res); + expect(text).toContain("agents.list[].tools.elevated.allowFrom.whatsapp"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("allows elevated when both global and per-agent allowlists match", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + ...makeCommandMessage("/elevated on", "+1333"), + SessionKey: "agent:work:main", + }, + {}, + makeWorkElevatedAllowlistConfig(home), + ); + + const text = replyText(res); + expect(text).toContain("Elevated mode set to ask"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("warns when elevated is used in direct runtime", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + makeCommandMessage("/elevated off"), + {}, + makeAllowlistedElevatedConfig(home, { sandbox: { mode: "off" } }), + ); + + const text = replyText(res); + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Runtime is direct; sandboxing does not apply."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("rejects invalid elevated level", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + makeCommandMessage("/elevated maybe"), + {}, + makeAllowlistedElevatedConfig(home), + ); + + const text = replyText(res); + expect(text).toContain("Unrecognized elevated level"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("handles multiple directives in a single message", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + makeCommandMessage("/elevated off\n/verbose on"), + {}, + makeAllowlistedElevatedConfig(home), + ); + + const text = replyText(res); + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Verbose logging enabled."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("shows elevated off in status when per-agent elevated is disabled", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( From c9fbcf39ee9f59e6d9b333a13678c30e5da6f80b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:08:30 +0000 Subject: [PATCH 011/314] test: merge fuzzy model directive shards --- ...tches-fuzzy-selection-is-ambiguous.test.ts | 176 +++++++++++++++ ...uzzy-model-matches-model-directive.test.ts | 203 ------------------ 2 files changed, 176 insertions(+), 203 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index 098728deb49..ca691e4e2ef 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -2,6 +2,7 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { drainSystemEvents } from "../infra/system-events.js"; @@ -39,9 +40,184 @@ function makeModelSwitchConfig(home: string) { }); } +function makeMoonshotConfig(home: string, storePath: string) { + return { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "openclaw"), + models: { + "anthropic/claude-opus-4-5": {}, + "moonshot/kimi-k2-0905-preview": {}, + }, + }, + }, + models: { + mode: "merge", + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2")], + }, + }, + }, + session: { store: storePath }, + } as unknown as OpenClawConfig; +} + describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); + async function runMoonshotModelDirective(params: { + home: string; + storePath: string; + body: string; + }) { + return await getReplyFromConfig( + { Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeMoonshotConfig(params.home, params.storePath), + ); + } + + function expectMoonshotSelectionFromResponse(params: { + response: Awaited>; + storePath: string; + }) { + const text = Array.isArray(params.response) ? params.response[0]?.text : params.response?.text; + expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview."); + assertModelSelection(params.storePath, { + provider: "moonshot", + model: "kimi-k2-0905-preview", + }); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + } + + it("supports fuzzy model matches on /model directive", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + + const res = await runMoonshotModelDirective({ + home, + storePath, + body: "/model kimi", + }); + + expectMoonshotSelectionFromResponse({ response: res, storePath }); + }); + }); + it("resolves provider-less exact model ids via fuzzy matching when unambiguous", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + + const res = await runMoonshotModelDirective({ + home, + storePath, + body: "/model kimi-k2-0905-preview", + }); + + expectMoonshotSelectionFromResponse({ response: res, storePath }); + }); + }); + it("supports fuzzy matches within a provider on /model provider/model", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + + const res = await runMoonshotModelDirective({ + home, + storePath, + body: "/model moonshot/kimi", + }); + + expectMoonshotSelectionFromResponse({ response: res, storePath }); + }); + }); + it("picks the best fuzzy match when multiple models match", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + + await getReplyFromConfig( + { Body: "/model minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + { + agents: { + defaults: { + model: { primary: "minimax/MiniMax-M2.1" }, + workspace: path.join(home, "openclaw"), + models: { + "minimax/MiniMax-M2.1": {}, + "minimax/MiniMax-M2.1-lightning": {}, + "lmstudio/minimax-m2.1-gs32": {}, + }, + }, + }, + models: { + mode: "merge", + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "sk-test", + api: "anthropic-messages", + models: [makeModelDefinition("MiniMax-M2.1", "MiniMax M2.1")], + }, + lmstudio: { + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "lmstudio", + api: "openai-responses", + models: [makeModelDefinition("minimax-m2.1-gs32", "MiniMax M2.1 GS32")], + }, + }, + }, + session: { store: storePath }, + } as unknown as OpenClawConfig, + ); + + assertModelSelection(storePath); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("picks the best fuzzy match within a provider", async () => { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + + await getReplyFromConfig( + { Body: "/model minimax/m2.1", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + { + agents: { + defaults: { + model: { primary: "minimax/MiniMax-M2.1" }, + workspace: path.join(home, "openclaw"), + models: { + "minimax/MiniMax-M2.1": {}, + "minimax/MiniMax-M2.1-lightning": {}, + }, + }, + }, + models: { + mode: "merge", + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "sk-test", + api: "anthropic-messages", + models: [ + makeModelDefinition("MiniMax-M2.1", "MiniMax M2.1"), + makeModelDefinition("MiniMax-M2.1-lightning", "MiniMax M2.1 Lightning"), + ], + }, + }, + }, + session: { store: storePath }, + } as unknown as OpenClawConfig, + ); + + assertModelSelection(storePath); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("prefers alias matches when fuzzy selection is ambiguous", async () => { await withTempHome(async (home) => { const storePath = sessionStorePath(home); diff --git a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts deleted file mode 100644 index d73b1c15179..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { - assertModelSelection, - installDirectiveBehaviorE2EHooks, - runEmbeddedPiAgent, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { getReplyFromConfig } from "./reply.js"; - -function makeModelDefinition(id: string, name: string) { - return { - id, - name, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8192, - }; -} - -function makeMoonshotConfig(home: string, storePath: string) { - return { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: path.join(home, "openclaw"), - models: { - "anthropic/claude-opus-4-5": {}, - "moonshot/kimi-k2-0905-preview": {}, - }, - }, - }, - models: { - mode: "merge", - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - apiKey: "sk-test", - api: "openai-completions", - models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2")], - }, - }, - }, - session: { store: storePath }, - } as unknown as OpenClawConfig; -} - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - async function runMoonshotModelDirective(params: { - home: string; - storePath: string; - body: string; - }) { - return await getReplyFromConfig( - { Body: params.body, From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeMoonshotConfig(params.home, params.storePath), - ); - } - - function expectMoonshotSelectionFromResponse(params: { - response: Awaited>; - storePath: string; - }) { - const text = Array.isArray(params.response) ? params.response[0]?.text : params.response?.text; - expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview."); - assertModelSelection(params.storePath, { - provider: "moonshot", - model: "kimi-k2-0905-preview", - }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - } - - it("supports fuzzy model matches on /model directive", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await runMoonshotModelDirective({ - home, - storePath, - body: "/model kimi", - }); - - expectMoonshotSelectionFromResponse({ response: res, storePath }); - }); - }); - it("resolves provider-less exact model ids via fuzzy matching when unambiguous", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await runMoonshotModelDirective({ - home, - storePath, - body: "/model kimi-k2-0905-preview", - }); - - expectMoonshotSelectionFromResponse({ response: res, storePath }); - }); - }); - it("supports fuzzy matches within a provider on /model provider/model", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - const res = await runMoonshotModelDirective({ - home, - storePath, - body: "/model moonshot/kimi", - }); - - expectMoonshotSelectionFromResponse({ response: res, storePath }); - }); - }); - it("picks the best fuzzy match when multiple models match", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - await getReplyFromConfig( - { Body: "/model minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "minimax/MiniMax-M2.1" }, - workspace: path.join(home, "openclaw"), - models: { - "minimax/MiniMax-M2.1": {}, - "minimax/MiniMax-M2.1-lightning": {}, - "lmstudio/minimax-m2.1-gs32": {}, - }, - }, - }, - models: { - mode: "merge", - providers: { - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - apiKey: "sk-test", - api: "anthropic-messages", - models: [makeModelDefinition("MiniMax-M2.1", "MiniMax M2.1")], - }, - lmstudio: { - baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", - api: "openai-responses", - models: [makeModelDefinition("minimax-m2.1-gs32", "MiniMax M2.1 GS32")], - }, - }, - }, - session: { store: storePath }, - } as unknown as OpenClawConfig, - ); - - assertModelSelection(storePath); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("picks the best fuzzy match within a provider", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - - await getReplyFromConfig( - { Body: "/model minimax/m2.1", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - { - agents: { - defaults: { - model: { primary: "minimax/MiniMax-M2.1" }, - workspace: path.join(home, "openclaw"), - models: { - "minimax/MiniMax-M2.1": {}, - "minimax/MiniMax-M2.1-lightning": {}, - }, - }, - }, - models: { - mode: "merge", - providers: { - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - apiKey: "sk-test", - api: "anthropic-messages", - models: [ - makeModelDefinition("MiniMax-M2.1", "MiniMax M2.1"), - makeModelDefinition("MiniMax-M2.1-lightning", "MiniMax M2.1 Lightning"), - ], - }, - }, - }, - session: { store: storePath }, - } as unknown as OpenClawConfig, - ); - - assertModelSelection(storePath); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); From f6ee1c99a7999e83c7146495650c9474bcafab92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:11:39 +0000 Subject: [PATCH 012/314] test: merge thinking and queue directive shards --- ...ccepts-thinking-xhigh-codex-models.test.ts | 190 ------------------ ...ng-mixed-messages-acks-immediately.test.ts | 173 ++++++++++++++++ 2 files changed, 173 insertions(+), 190 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts deleted file mode 100644 index 40a145220c1..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - installDirectiveBehaviorE2EHooks, - makeWhatsAppDirectiveConfig, - replyText, - replyTexts, - runEmbeddedPiAgent, - sessionStorePath, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { getReplyFromConfig } from "./reply.js"; - -async function writeSkill(params: { workspaceDir: string; name: string; description: string }) { - const { workspaceDir, name, description } = params; - const skillDir = path.join(workspaceDir, "skills", name); - await fs.mkdir(skillDir, { recursive: true }); - await fs.writeFile( - path.join(skillDir, "SKILL.md"), - `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`, - "utf-8", - ); -} - -async function runThinkingDirective(home: string, model: string) { - const res = await getReplyFromConfig( - { - Body: "/thinking xhigh", - From: "+1004", - To: "+2000", - CommandAuthorized: true, - }, - {}, - makeWhatsAppDirectiveConfig(home, { model }, { session: { store: sessionStorePath(home) } }), - ); - return replyTexts(res); -} - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("accepts /thinking xhigh for codex models", async () => { - await withTempHome(async (home) => { - const texts = await runThinkingDirective(home, "openai-codex/gpt-5.2-codex"); - expect(texts).toContain("Thinking level set to xhigh."); - }); - }); - it("accepts /thinking xhigh for openai gpt-5.2", async () => { - await withTempHome(async (home) => { - const texts = await runThinkingDirective(home, "openai/gpt-5.2"); - expect(texts).toContain("Thinking level set to xhigh."); - }); - }); - it("rejects /thinking xhigh for non-codex models", async () => { - await withTempHome(async (home) => { - const texts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); - expect(texts).toContain( - 'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', - ); - }); - }); - it("keeps reserved command aliases from matching after trimming", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/help", - From: "+1222", - To: "+1222", - CommandAuthorized: true, - }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: "anthropic/claude-opus-4-5", - models: { - "anthropic/claude-opus-4-5": { alias: " help " }, - }, - }, - { session: { store: sessionStorePath(home) } }, - ), - ); - - const text = replyText(res); - expect(text).toContain("Help"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("treats skill commands as reserved for model aliases", async () => { - await withTempHome(async (home) => { - const workspace = path.join(home, "openclaw"); - await writeSkill({ - workspaceDir: workspace, - name: "demo-skill", - description: "Demo skill", - }); - - await getReplyFromConfig( - { - Body: "/demo_skill", - From: "+1222", - To: "+1222", - CommandAuthorized: true, - }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: "anthropic/claude-opus-4-5", - workspace, - models: { - "anthropic/claude-opus-4-5": { alias: "demo_skill" }, - }, - }, - { session: { store: sessionStorePath(home) } }, - ), - ); - - expect(runEmbeddedPiAgent).toHaveBeenCalled(); - const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain('Use the "demo-skill" skill'); - }); - }); - it("errors on invalid queue options", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/queue collect debounce:bogus cap:zero drop:maybe", - From: "+1222", - To: "+1222", - CommandAuthorized: true, - }, - {}, - makeWhatsAppDirectiveConfig( - home, - { model: "anthropic/claude-opus-4-5" }, - { - session: { store: sessionStorePath(home) }, - }, - ), - ); - - const text = replyText(res); - expect(text).toContain("Invalid debounce"); - expect(text).toContain("Invalid cap"); - expect(text).toContain("Invalid drop policy"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("shows current queue settings when /queue has no arguments", async () => { - await withTempHome(async (home) => { - const res = await getReplyFromConfig( - { - Body: "/queue", - From: "+1222", - To: "+1222", - Provider: "whatsapp", - CommandAuthorized: true, - }, - {}, - makeWhatsAppDirectiveConfig( - home, - { model: "anthropic/claude-opus-4-5" }, - { - messages: { - queue: { - mode: "collect", - debounceMs: 1500, - cap: 9, - drop: "summarize", - }, - }, - session: { store: sessionStorePath(home) }, - }, - ), - ); - - const text = replyText(res); - expect(text).toContain( - "Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.", - ); - expect(text).toContain( - "Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize.", - ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts index 78a7fb2792f..56e1e5beaaa 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts @@ -1,4 +1,6 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; +import fs from "node:fs/promises"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js"; import { @@ -13,6 +15,31 @@ import { } from "./reply.directive.directive-behavior.e2e-harness.js"; import { getReplyFromConfig } from "./reply.js"; +async function writeSkill(params: { workspaceDir: string; name: string; description: string }) { + const { workspaceDir, name, description } = params; + const skillDir = path.join(workspaceDir, "skills", name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`, + "utf-8", + ); +} + +async function runThinkingDirective(home: string, model: string) { + const res = await getReplyFromConfig( + { + Body: "/thinking xhigh", + From: "+1004", + To: "+2000", + CommandAuthorized: true, + }, + {}, + makeWhatsAppDirectiveConfig(home, { model }, { session: { store: sessionStorePath(home) } }), + ); + return replyTexts(res); +} + async function runThinkDirectiveAndGetText(home: string): Promise { const res = await getReplyFromConfig( { Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true }, @@ -235,4 +262,150 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); + it("accepts /thinking xhigh for codex models", async () => { + await withTempHome(async (home) => { + const texts = await runThinkingDirective(home, "openai-codex/gpt-5.2-codex"); + expect(texts).toContain("Thinking level set to xhigh."); + }); + }); + it("accepts /thinking xhigh for openai gpt-5.2", async () => { + await withTempHome(async (home) => { + const texts = await runThinkingDirective(home, "openai/gpt-5.2"); + expect(texts).toContain("Thinking level set to xhigh."); + }); + }); + it("rejects /thinking xhigh for non-codex models", async () => { + await withTempHome(async (home) => { + const texts = await runThinkingDirective(home, "openai/gpt-4.1-mini"); + expect(texts).toContain( + 'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', + ); + }); + }); + it("keeps reserved command aliases from matching after trimming", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/help", + From: "+1222", + To: "+1222", + CommandAuthorized: true, + }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + models: { + "anthropic/claude-opus-4-5": { alias: " help " }, + }, + }, + { session: { store: sessionStorePath(home) } }, + ), + ); + + const text = replyText(res); + expect(text).toContain("Help"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("treats skill commands as reserved for model aliases", async () => { + await withTempHome(async (home) => { + const workspace = path.join(home, "openclaw"); + await writeSkill({ + workspaceDir: workspace, + name: "demo-skill", + description: "Demo skill", + }); + + await getReplyFromConfig( + { + Body: "/demo_skill", + From: "+1222", + To: "+1222", + CommandAuthorized: true, + }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: "anthropic/claude-opus-4-5", + workspace, + models: { + "anthropic/claude-opus-4-5": { alias: "demo_skill" }, + }, + }, + { session: { store: sessionStorePath(home) } }, + ), + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalled(); + const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain('Use the "demo-skill" skill'); + }); + }); + it("errors on invalid queue options", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/queue collect debounce:bogus cap:zero drop:maybe", + From: "+1222", + To: "+1222", + CommandAuthorized: true, + }, + {}, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + session: { store: sessionStorePath(home) }, + }, + ), + ); + + const text = replyText(res); + expect(text).toContain("Invalid debounce"); + expect(text).toContain("Invalid cap"); + expect(text).toContain("Invalid drop policy"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("shows current queue settings when /queue has no arguments", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/queue", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + CommandAuthorized: true, + }, + {}, + makeWhatsAppDirectiveConfig( + home, + { model: "anthropic/claude-opus-4-5" }, + { + messages: { + queue: { + mode: "collect", + debounceMs: 1500, + cap: 9, + drop: "summarize", + }, + }, + session: { store: sessionStorePath(home) }, + }, + ), + ); + + const text = replyText(res); + expect(text).toContain( + "Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.", + ); + expect(text).toContain( + "Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize.", + ); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); }); From 67bccc1fa02ec15d6989d12a9a6aad5d56c13028 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:18:03 +0000 Subject: [PATCH 013/314] test: merge allow-from trigger shard and dedupe inline cases --- ...s-activation-from-allowfrom-groups.test.ts | 146 -------------- ...ne-commands-strips-it-before-agent.test.ts | 189 +++++++++++++----- 2 files changed, 140 insertions(+), 195 deletions(-) delete mode 100644 src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts deleted file mode 100644 index 1b4866aad34..00000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - getRunEmbeddedPiAgentMock, - installTriggerHandlingReplyHarness, - makeCfg, - runGreetingPromptForBareNewOrReset, - withTempHome, -} from "./reply.triggers.trigger-handling.test-harness.js"; - -let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -installTriggerHandlingReplyHarness((loader) => { - getReplyFromConfig = loader; -}); - -async function expectResetBlockedForNonOwner(params: { - home: string; - commandAuthorized: boolean; - getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -}): Promise { - const { home, commandAuthorized, getReplyFromConfig } = params; - const cfg = makeCfg(home); - cfg.channels ??= {}; - cfg.channels.whatsapp = { - ...cfg.channels.whatsapp, - allowFrom: ["+1999"], - }; - cfg.session = { - ...cfg.session, - store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`), - }; - const res = await getReplyFromConfig( - { - Body: "/reset", - From: "+1003", - To: "+2000", - CommandAuthorized: commandAuthorized, - }, - {}, - cfg, - ); - expect(res).toBeUndefined(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); -} - -describe("trigger handling", () => { - it("allows /activation from allowFrom in groups", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - const res = await getReplyFromConfig( - { - Body: "/activation mention", - From: "123@g.us", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - SenderE164: "+999", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("⚙️ Group activation set to mention."); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("injects group activation context into the system prompt", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeCfg(home); - cfg.channels ??= {}; - cfg.channels.whatsapp = { - ...cfg.channels.whatsapp, - allowFrom: ["*"], - groups: { "*": { requireMention: false } }, - }; - cfg.messages = { - ...cfg.messages, - groupChat: {}, - }; - - const res = await getReplyFromConfig( - { - Body: "hello group", - From: "123@g.us", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - SenderE164: "+2000", - GroupSubject: "Test Group", - GroupMembers: "Alice (+1), Bob (+2)", - }, - {}, - cfg, - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const extra = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.extraSystemPrompt ?? ""; - expect(extra).toContain('"chat_type": "group"'); - expect(extra).toContain("Activation: always-on"); - }); - }); - - it("runs a greeting prompt for a bare /reset", async () => { - await withTempHome(async (home) => { - await runGreetingPromptForBareNewOrReset({ home, body: "/reset", getReplyFromConfig }); - }); - }); - - it("runs a greeting prompt for a bare /new", async () => { - await withTempHome(async (home) => { - await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig }); - }); - }); - - it("does not reset for unauthorized /reset", async () => { - await withTempHome(async (home) => { - await expectResetBlockedForNonOwner({ - home, - commandAuthorized: false, - getReplyFromConfig, - }); - }); - }); - - it("blocks /reset for non-owner senders", async () => { - await withTempHome(async (home) => { - await expectResetBlockedForNonOwner({ - home, - commandAuthorized: true, - getReplyFromConfig, - }); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index 08d80e03c58..3f92d80c11e 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -1,4 +1,6 @@ import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { expectInlineCommandHandledAndStripped, @@ -7,6 +9,9 @@ import { loadGetReplyFromConfig, MAIN_SESSION_KEY, makeCfg, + mockRunEmbeddedPiAgentOk, + requireSessionStorePath, + runGreetingPromptForBareNewOrReset, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; @@ -30,12 +35,33 @@ function makeUnauthorizedWhatsAppCfg(home: string) { }; } -function requireSessionStorePath(cfg: { session?: { store?: string } }): string { - const storePath = cfg.session?.store; - if (!storePath) { - throw new Error("expected session store path"); - } - return storePath; +async function expectResetBlockedForNonOwner(params: { + home: string; + commandAuthorized: boolean; +}): Promise { + const { home } = params; + const cfg = makeCfg(home); + cfg.channels ??= {}; + cfg.channels.whatsapp = { + ...cfg.channels.whatsapp, + allowFrom: ["+1999"], + }; + cfg.session = { + ...cfg.session, + store: join(tmpdir(), `openclaw-session-test-${Date.now()}.json`), + }; + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + CommandAuthorized: params.commandAuthorized, + }, + {}, + cfg, + ); + expect(res).toBeUndefined(); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); } async function expectUnauthorizedCommandDropped(home: string, body: "/status" | "/whoami") { @@ -59,15 +85,7 @@ async function expectUnauthorizedCommandDropped(home: string, body: "/status" | } function mockEmbeddedOk() { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - return runEmbeddedPiAgentMock; + return mockRunEmbeddedPiAgentOk("ok"); } async function runInlineUnauthorizedCommand(params: { @@ -90,6 +108,96 @@ async function runInlineUnauthorizedCommand(params: { } describe("trigger handling", () => { + it("allows /activation from allowFrom in groups", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + const res = await getReplyFromConfig( + { + Body: "/activation mention", + From: "123@g.us", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + SenderE164: "+999", + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("⚙️ Group activation set to mention."); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("injects group activation context into the system prompt", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = makeCfg(home); + cfg.channels ??= {}; + cfg.channels.whatsapp = { + ...cfg.channels.whatsapp, + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }; + cfg.messages = { + ...cfg.messages, + groupChat: {}, + }; + + const res = await getReplyFromConfig( + { + Body: "hello group", + From: "123@g.us", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + SenderE164: "+2000", + GroupSubject: "Test Group", + GroupMembers: "Alice (+1), Bob (+2)", + }, + {}, + cfg, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); + const extra = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.extraSystemPrompt ?? ""; + expect(extra).toContain('"chat_type": "group"'); + expect(extra).toContain("Activation: always-on"); + }); + }); + + it("runs a greeting prompt for a bare /reset", async () => { + await withTempHome(async (home) => { + await runGreetingPromptForBareNewOrReset({ home, body: "/reset", getReplyFromConfig }); + }); + }); + + it("runs a greeting prompt for a bare /new", async () => { + await withTempHome(async (home) => { + await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig }); + }); + }); + + it("blocks /reset for unauthorized sender scenarios", async () => { + await withTempHome(async (home) => { + for (const commandAuthorized of [false, true]) { + await expectResetBlockedForNonOwner({ + home, + commandAuthorized, + }); + } + }); + }); + it("handles inline /commands and strips it before the agent", async () => { await withTempHome(async (home) => { await expectInlineCommandHandledAndStripped({ @@ -129,45 +237,28 @@ describe("trigger handling", () => { }); }); - it("drops /status for unauthorized senders", async () => { + it("drops top-level restricted commands for unauthorized senders", async () => { await withTempHome(async (home) => { - await expectUnauthorizedCommandDropped(home, "/status"); + for (const command of ["/status", "/whoami"] as const) { + await expectUnauthorizedCommandDropped(home, command); + } }); }); - it("drops /whoami for unauthorized senders", async () => { + it("keeps inline commands for unauthorized senders", async () => { await withTempHome(async (home) => { - await expectUnauthorizedCommandDropped(home, "/whoami"); - }); - }); - - it("keeps inline /status for unauthorized senders", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOk(); - const res = await runInlineUnauthorizedCommand({ - home, - command: "/status", - }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain("/status"); - }); - }); - - it("keeps inline /help for unauthorized senders", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOk(); - const res = await runInlineUnauthorizedCommand({ - home, - command: "/help", - }); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("ok"); - expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain("/help"); + for (const command of ["/status", "/help"] as const) { + const runEmbeddedPiAgentMock = mockEmbeddedOk(); + const res = await runInlineUnauthorizedCommand({ + home, + command, + }); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); + const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? ""; + expect(prompt).toContain(command); + } }); }); From 89a46950204b70b70c0be4bc0d8ef2431b59342b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:30:47 +0000 Subject: [PATCH 014/314] test: consolidate shard tests for faster trigger/directive suites --- scripts/test-parallel.mjs | 8 +- ...nk-low-reasoning-capable-models-no.test.ts | 138 ++++++++ ...ists-allowlisted-models-model-list.test.ts | 183 ----------- ...proved-sender-toggle-elevated-mode.test.ts | 235 -------------- ...ne-commands-strips-it-before-agent.test.ts | 216 +++++++++++++ ...-error-cause-embedded-agent-throws.test.ts | 303 ------------------ ...targets-active-session-native-stop.test.ts | 280 ++++++++++++++-- 7 files changed, 611 insertions(+), 752 deletions(-) delete mode 100644 src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts delete mode 100644 src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts delete mode 100644 src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 5abfdf0fa11..94d8c0726dd 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -49,15 +49,9 @@ const unitIsolatedFilesRaw = [ "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts", + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts", "src/web/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", // Setup-heavy bot bootstrap suite. "src/telegram/bot.create-telegram-bot.test.ts", diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index e517c244e39..dc78e7ac553 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -2,6 +2,7 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; import { describe, expect, it, vi } from "vitest"; import { loadSessionStore } from "../config/sessions.js"; import { + assertModelSelection, installDirectiveBehaviorE2EHooks, loadModelCatalog, makeEmbeddedTextResult, @@ -13,6 +14,7 @@ import { sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; import { getReplyFromConfig } from "./reply.js"; function makeDefaultModelConfig(home: string) { @@ -84,6 +86,142 @@ describe("directive behavior", () => { expectedLevel: "off", }); }); + it("aliases /model list to /models", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model list"); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("Use: /models "); + expect(text).toContain("Switch: /model "); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("shows current model when catalog is unavailable", async () => { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); + const text = await runModelDirectiveText(home, "/model"); + expect(text).toContain("Current: anthropic/claude-opus-4-5"); + expect(text).toContain("Switch: /model "); + expect(text).toContain("Browse: /models (providers) or /models (models)"); + expect(text).toContain("More: /model status"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("lists allowlisted models on /model status", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model status", { + includeSessionStore: false, + }); + expect(text).toContain("anthropic/claude-opus-4-5"); + expect(text).toContain("openai/gpt-4.1-mini"); + expect(text).not.toContain("claude-sonnet-4-1"); + expect(text).toContain("auth:"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("includes catalog providers when no allowlist is set", async () => { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValue([ + { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, + { id: "grok-4", name: "Grok 4", provider: "xai" }, + ]); + const text = await runModelDirectiveText(home, "/model list", { + defaults: { + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: ["openai/gpt-4.1-mini"], + }, + imageModel: { primary: "minimax/MiniMax-M2.1" }, + models: undefined, + }, + }); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("- xai"); + expect(text).toContain("Use: /models "); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("lists config-only providers when catalog is present", async () => { + await withTempHome(async (home) => { + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + provider: "anthropic", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + }, + { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, + ]); + const text = await runModelDirectiveText(home, "/models minimax", { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + "minimax/MiniMax-M2.1": { alias: "minimax" }, + }, + }, + extra: { + models: { + mode: "merge", + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], + }, + }, + }, + }, + }); + expect(text).toContain("Models (minimax"); + expect(text).toContain("minimax/MiniMax-M2.1"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("does not repeat missing auth labels on /model list", async () => { + await withTempHome(async (home) => { + const text = await runModelDirectiveText(home, "/model list", { + defaults: { + models: { + "anthropic/claude-opus-4-5": {}, + }, + }, + }); + expect(text).toContain("Providers:"); + expect(text).not.toContain("missing (missing)"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("sets model override on /model directive", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + + await getReplyFromConfig( + { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true }, + {}, + makeWhatsAppDirectiveConfig( + home, + { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, + }, + { session: { store: storePath } }, + ), + ); + + assertModelSelection(storePath, { + model: "gpt-4.1-mini", + provider: "openai", + }); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("ignores inline /model and uses the default model", async () => { await withTempHome(async (home) => { mockEmbeddedTextResult("done"); diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts deleted file mode 100644 index 759136fc65d..00000000000 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it, vi } from "vitest"; -import { - assertModelSelection, - installDirectiveBehaviorE2EHooks, - loadModelCatalog, - makeWhatsAppDirectiveConfig, - runEmbeddedPiAgent, - sessionStorePath, - withTempHome, -} from "./reply.directive.directive-behavior.e2e-harness.js"; -import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; -import { getReplyFromConfig } from "./reply.js"; - -describe("directive behavior", () => { - installDirectiveBehaviorE2EHooks(); - - it("aliases /model list to /models", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model list"); - expect(text).toContain("Providers:"); - expect(text).toContain("- anthropic"); - expect(text).toContain("- openai"); - expect(text).toContain("Use: /models "); - expect(text).toContain("Switch: /model "); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("shows current model when catalog is unavailable", async () => { - await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); - const text = await runModelDirectiveText(home, "/model"); - expect(text).toContain("Current: anthropic/claude-opus-4-5"); - expect(text).toContain("Switch: /model "); - expect(text).toContain("Browse: /models (providers) or /models (models)"); - expect(text).toContain("More: /model status"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("lists allowlisted models on /model status", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model status", { - includeSessionStore: false, - }); - expect(text).toContain("anthropic/claude-opus-4-5"); - expect(text).toContain("openai/gpt-4.1-mini"); - expect(text).not.toContain("claude-sonnet-4-1"); - expect(text).toContain("auth:"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("includes catalog providers when no allowlist is set", async () => { - await withTempHome(async (home) => { - vi.mocked(loadModelCatalog).mockResolvedValue([ - { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, - { id: "grok-4", name: "Grok 4", provider: "xai" }, - ]); - const text = await runModelDirectiveText(home, "/model list", { - defaults: { - model: { - primary: "anthropic/claude-opus-4-5", - fallbacks: ["openai/gpt-4.1-mini"], - }, - imageModel: { primary: "minimax/MiniMax-M2.1" }, - models: undefined, - }, - }); - expect(text).toContain("Providers:"); - expect(text).toContain("- anthropic"); - expect(text).toContain("- openai"); - expect(text).toContain("- xai"); - expect(text).toContain("Use: /models "); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("lists config-only providers when catalog is present", async () => { - await withTempHome(async (home) => { - // Catalog present but missing custom providers: /model should still include - // allowlisted provider/model keys from config. - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { - provider: "anthropic", - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - }, - { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, - ]); - const text = await runModelDirectiveText(home, "/models minimax", { - defaults: { - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - "minimax/MiniMax-M2.1": { alias: "minimax" }, - }, - }, - extra: { - models: { - mode: "merge", - providers: { - minimax: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }], - }, - }, - }, - }, - }); - expect(text).toContain("Models (minimax"); - expect(text).toContain("minimax/MiniMax-M2.1"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("does not repeat missing auth labels on /model list", async () => { - await withTempHome(async (home) => { - const text = await runModelDirectiveText(home, "/model list", { - defaults: { - models: { - "anthropic/claude-opus-4-5": {}, - }, - }, - }); - expect(text).toContain("Providers:"); - expect(text).not.toContain("missing (missing)"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("sets model override on /model directive", async () => { - await withTempHome(async (home) => { - const storePath = sessionStorePath(home); - - await getReplyFromConfig( - { Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: { primary: "anthropic/claude-opus-4-5" }, - models: { - "anthropic/claude-opus-4-5": {}, - "openai/gpt-4.1-mini": {}, - }, - }, - { session: { store: storePath } }, - ), - ); - - assertModelSelection(storePath, { - model: "gpt-4.1-mini", - provider: "openai", - }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); - it("supports model aliases on /model directive", async () => { - await withTempHome(async (home) => { - const storePath = sessionStorePath(home); - - await getReplyFromConfig( - { Body: "/model Opus", From: "+1222", To: "+1222", CommandAuthorized: true }, - {}, - makeWhatsAppDirectiveConfig( - home, - { - model: { primary: "openai/gpt-4.1-mini" }, - models: { - "openai/gpt-4.1-mini": {}, - "anthropic/claude-opus-4-5": { alias: "Opus" }, - }, - }, - { session: { store: storePath } }, - ), - ); - - assertModelSelection(storePath, { - model: "claude-opus-4-5", - provider: "anthropic", - }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts deleted file mode 100644 index 10152a8bf5b..00000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import fs from "node:fs/promises"; -import { beforeAll, describe, expect, it } from "vitest"; -import { - expectDirectElevatedToggleOn, - getRunEmbeddedPiAgentMock, - installTriggerHandlingE2eTestHooks, - loadGetReplyFromConfig, - MAIN_SESSION_KEY, - makeCfg, - makeWhatsAppElevatedCfg, - readSessionStore, - requireSessionStorePath, - withTempHome, -} from "./reply.triggers.trigger-handling.test-harness.js"; - -let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -beforeAll(async () => { - getReplyFromConfig = await loadGetReplyFromConfig(); -}); - -installTriggerHandlingE2eTestHooks(); - -describe("trigger handling", () => { - it("allows approved sender to toggle elevated mode", async () => { - await expectDirectElevatedToggleOn({ getReplyFromConfig }); - }); - it("rejects elevated toggles when disabled", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("tools.elevated.enabled"); - - const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); - const store = JSON.parse(storeRaw) as Record; - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); - }); - }); - - it("allows elevated off in groups without mention", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated off", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - ChatType: "group", - WasMentioned: false, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode disabled."); - - const store = await readSessionStore(cfg); - expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off"); - }); - }); - - it("allows elevated directive in groups when mentioned", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - ChatType: "group", - WasMentioned: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode set to ask"); - - const store = await readSessionStore(cfg); - expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on"); - }); - }); - - it("ignores elevated directive in groups when not mentioned", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - ChatType: "group", - WasMentioned: false, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBeUndefined(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("ignores inline elevated directive for unapproved sender", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeWhatsAppElevatedCfg(home); - - const res = await getReplyFromConfig( - { - Body: "please /elevated on now", - From: "+2000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2000", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).not.toContain("elevated is not available right now"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalled(); - }); - }); - - it("uses tools.elevated.allowFrom.discord for elevated approval", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "discord:123", - To: "user:123", - Provider: "discord", - SenderName: "Peter Steinberger", - SenderUsername: "steipete", - SenderTag: "steipete", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode set to ask"); - - const store = await readSessionStore(cfg); - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); - }); - }); - - it("treats explicit discord elevated allowlist as override", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - cfg.tools = { - elevated: { - allowFrom: { discord: [] }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "discord:123", - To: "user:123", - Provider: "discord", - SenderName: "steipete", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("tools.elevated.allowFrom.discord"); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("returns a context overflow fallback when the embedded agent throws", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockRejectedValue(new Error("Context window exceeded")); - - const res = await getReplyFromConfig( - { - Body: "hello", - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe( - "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.", - ); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index 3f92d80c11e..6072608aeb7 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -4,12 +4,15 @@ import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { expectInlineCommandHandledAndStripped, + expectDirectElevatedToggleOn, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, MAIN_SESSION_KEY, makeCfg, + makeWhatsAppElevatedCfg, mockRunEmbeddedPiAgentOk, + readSessionStore, requireSessionStorePath, runGreetingPromptForBareNewOrReset, withTempHome, @@ -307,4 +310,217 @@ describe("trigger handling", () => { expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny"); }); }); + + it("allows approved sender to toggle elevated mode", async () => { + await expectDirectElevatedToggleOn({ getReplyFromConfig }); + }); + + it("rejects elevated toggles when disabled", async () => { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("tools.elevated.enabled"); + + const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); + const store = JSON.parse(storeRaw) as Record; + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); + }); + }); + + it("allows elevated off in groups without mention", async () => { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + + const store = await readSessionStore(cfg); + expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off"); + }); + }); + + it("allows elevated directive in groups when mentioned", async () => { + await withTempHome(async (home) => { + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + ChatType: "group", + WasMentioned: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode set to ask"); + + const store = await readSessionStore(cfg); + expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on"); + }); + }); + + it("ignores elevated directive in groups when not mentioned", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBeUndefined(); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("ignores inline elevated directive for unapproved sender", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = makeWhatsAppElevatedCfg(home); + + const res = await getReplyFromConfig( + { + Body: "please /elevated on now", + From: "+2000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).not.toContain("elevated is not available right now"); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalled(); + }); + }); + + it("uses tools.elevated.allowFrom.discord for elevated approval", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } }; + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "discord:123", + To: "user:123", + Provider: "discord", + SenderName: "Peter Steinberger", + SenderUsername: "steipete", + SenderTag: "steipete", + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode set to ask"); + + const store = await readSessionStore(cfg); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); + }); + }); + + it("treats explicit discord elevated allowlist as override", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + cfg.tools = { + elevated: { + allowFrom: { discord: [] }, + }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "discord:123", + To: "user:123", + Provider: "discord", + SenderName: "steipete", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("tools.elevated.allowFrom.discord"); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("returns a context overflow fallback when the embedded agent throws", async () => { + await withTempHome(async (home) => { + getRunEmbeddedPiAgentMock().mockRejectedValue(new Error("Context window exceeded")); + + const res = await getReplyFromConfig( + { + Body: "hello", + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe( + "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.", + ); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts deleted file mode 100644 index 73bea2eece5..00000000000 --- a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { beforeAll, describe, expect, it } from "vitest"; -import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; -import { - getCompactEmbeddedPiSessionMock, - getRunEmbeddedPiAgentMock, - installTriggerHandlingE2eTestHooks, - MAIN_SESSION_KEY, - makeCfg, - mockRunEmbeddedPiAgentOk, - withTempHome, -} from "./reply.triggers.trigger-handling.test-harness.js"; -import { HEARTBEAT_TOKEN } from "./tokens.js"; - -let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -beforeAll(async () => { - ({ getReplyFromConfig } = await import("./reply.js")); -}); - -installTriggerHandlingE2eTestHooks(); - -const BASE_MESSAGE = { - Body: "hello", - From: "+1002", - To: "+2000", -} as const; - -function mockEmbeddedOkPayload() { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - return runEmbeddedPiAgentMock; -} - -function requireSessionStorePath(cfg: { session?: { store?: string } }): string { - const storePath = cfg.session?.store; - if (!storePath) { - throw new Error("expected session store path"); - } - return storePath; -} - -async function writeStoredModelOverride(cfg: ReturnType): Promise { - await fs.writeFile( - requireSessionStorePath(cfg), - JSON.stringify({ - [MAIN_SESSION_KEY]: { - sessionId: "main", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-5.2", - }, - }), - "utf-8", - ); -} - -function mockSuccessfulCompaction() { - getCompactEmbeddedPiSessionMock().mockResolvedValue({ - ok: true, - compacted: true, - result: { - summary: "summary", - firstKeptEntryId: "x", - tokensBefore: 12000, - }, - }); -} - -function replyText(res: Awaited>) { - return Array.isArray(res) ? res[0]?.text : res?.text; -} - -describe("trigger handling", () => { - it("includes the error cause when the embedded agent throws", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockRejectedValue(new Error("sandbox is not defined.")); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe( - "⚠️ Agent failed before reply: sandbox is not defined.\nLogs: openclaw logs --follow", - ); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - }); - }); - - it("uses heartbeat model override for heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); - const cfg = makeCfg(home); - await writeStoredModelOverride(cfg); - cfg.agents = { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, - }, - }; - - await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(call?.provider).toBe("anthropic"); - expect(call?.model).toBe("claude-haiku-4-5-20251001"); - }); - }); - - it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); - const cfg = makeCfg(home); - await writeStoredModelOverride(cfg); - await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(call?.provider).toBe("openai"); - expect(call?.model).toBe("gpt-5.2"); - }); - }); - - it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: HEARTBEAT_TOKEN }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - expect(res).toBeUndefined(); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - }); - }); - - it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("hello"); - }); - }); - - it("updates group activation when the owner sends /activation", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const cfg = makeCfg(home); - const res = await getReplyFromConfig( - { - Body: "/activation always", - From: "123@g.us", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - SenderE164: "+2000", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Group activation set to always"); - const store = JSON.parse(await fs.readFile(requireSessionStorePath(cfg), "utf-8")) as Record< - string, - { groupActivation?: string } - >; - expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); - - it("runs /compact as a gated command", async () => { - await withTempHome(async (home) => { - const storePath = join(tmpdir(), `openclaw-session-test-${Date.now()}.json`); - const cfg = makeCfg(home); - cfg.session = { ...cfg.session, store: storePath }; - mockSuccessfulCompaction(); - - const res = await getReplyFromConfig( - { - Body: "/compact focus on decisions", - From: "+1003", - To: "+2000", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = replyText(res); - expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - const store = loadSessionStore(storePath); - const sessionKey = resolveSessionKey("per-sender", { - Body: "/compact focus on decisions", - From: "+1003", - To: "+2000", - }); - expect(store[sessionKey]?.compactionCount).toBe(1); - }); - }); - - it("runs /compact for non-default agents without transcript path validation failures", async () => { - await withTempHome(async (home) => { - getCompactEmbeddedPiSessionMock().mockClear(); - mockSuccessfulCompaction(); - - const res = await getReplyFromConfig( - { - Body: "/compact", - From: "+1004", - To: "+2000", - SessionKey: "agent:worker1:telegram:12345", - CommandAuthorized: true, - }, - {}, - makeCfg(home), - ); - - const text = replyText(res); - expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); - expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain( - join("agents", "worker1", "sessions"), - ); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - }); - }); - - it("ignores think directives that only appear in the context wrapper", async () => { - await withTempHome(async (home) => { - mockRunEmbeddedPiAgentOk(); - - const res = await getReplyFromConfig( - { - Body: [ - "[Chat messages since your last reply - for context]", - "Peter: /thinking high [2025-12-05T21:45:00.000Z]", - "", - "[Current message - respond to this]", - "Give me the status", - ].join("\n"), - From: "+1002", - To: "+2000", - }, - {}, - makeCfg(home), - ); - - const text = replyText(res); - expect(text).toBe("ok"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const prompt = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.prompt ?? ""; - expect(prompt).toContain("Give me the status"); - expect(prompt).not.toContain("/thinking high"); - expect(prompt).not.toContain("/think high"); - }); - }); - - it("does not emit directive acks for heartbeats with /think", async () => { - await withTempHome(async (home) => { - mockRunEmbeddedPiAgentOk(); - - const res = await getReplyFromConfig( - { - Body: "HEARTBEAT /think:high", - From: "+1003", - To: "+1003", - }, - { isHeartbeat: true }, - makeCfg(home), - ); - - const text = replyText(res); - expect(text).toBe("ok"); - expect(text).not.toMatch(/Thinking level set/i); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts index dff015c0057..61b9be19d43 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts @@ -1,18 +1,23 @@ import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import type { OpenClawConfig } from "../config/config.js"; -import { loadSessionStore } from "../config/sessions.js"; +import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { getAbortEmbeddedPiRunMock, + getCompactEmbeddedPiSessionMock, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, MAIN_SESSION_KEY, makeCfg, + mockRunEmbeddedPiAgentOk, + requireSessionStorePath, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; +import { HEARTBEAT_TOKEN } from "./tokens.js"; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; let previousFastTestEnv: string | undefined; @@ -32,14 +37,11 @@ afterAll(() => { installTriggerHandlingE2eTestHooks(); const DEFAULT_SESSION_KEY = "telegram:slash:111"; - -function requireSessionStorePath(cfg: { session?: { store?: string } }): string { - const storePath = cfg.session?.store; - if (!storePath) { - throw new Error("expected session store path"); - } - return storePath; -} +const BASE_MESSAGE = { + Body: "hello", + From: "+1002", + To: "+2000", +} as const; function makeTelegramModelCommand(body: string, sessionKey = DEFAULT_SESSION_KEY) { return { @@ -70,27 +72,257 @@ async function runModelCommand(home: string, body: string, sessionKey = DEFAULT_ }; } -describe("trigger handling", () => { - it("shows a /model summary and points to /models", async () => { - await withTempHome(async (home) => { - const { normalized } = await runModelCommand(home, "/model"); +function maybeReplyText(reply: Awaited>) { + return Array.isArray(reply) ? reply[0]?.text : reply?.text; +} - expect(normalized).toContain("Current: anthropic/claude-opus-4-5"); - expect(normalized).toContain("/model to switch"); - expect(normalized).toContain("Tap below to browse models"); - expect(normalized).toContain("/model status for details"); - expect(normalized).not.toContain("reasoning"); - expect(normalized).not.toContain("image"); +function mockEmbeddedOkPayload() { + return mockRunEmbeddedPiAgentOk("ok"); +} + +async function writeStoredModelOverride(cfg: ReturnType): Promise { + await fs.writeFile( + requireSessionStorePath(cfg), + JSON.stringify({ + [MAIN_SESSION_KEY]: { + sessionId: "main", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-5.2", + }, + }), + "utf-8", + ); +} + +function mockSuccessfulCompaction() { + getCompactEmbeddedPiSessionMock().mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "x", + tokensBefore: 12000, + }, + }); +} + +describe("trigger handling", () => { + it("includes the error cause when the embedded agent throws", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockRejectedValue(new Error("sandbox is not defined.")); + + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + + expect(maybeReplyText(res)).toBe( + "⚠️ Agent failed before reply: sandbox is not defined.\nLogs: openclaw logs --follow", + ); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); }); }); - it("aliases /model list to /models", async () => { + it("uses heartbeat model override for heartbeat runs", async () => { await withTempHome(async (home) => { - const { normalized } = await runModelCommand(home, "/model list"); + const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); + const cfg = makeCfg(home); + await writeStoredModelOverride(cfg); + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + }, + }; - expect(normalized).toContain("Providers:"); - expect(normalized).toContain("Use: /models "); - expect(normalized).toContain("Switch: /model "); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); + + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + expect(call?.provider).toBe("anthropic"); + expect(call?.model).toBe("claude-haiku-4-5-20251001"); + }); + }); + + it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); + const cfg = makeCfg(home); + await writeStoredModelOverride(cfg); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); + + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + expect(call?.provider).toBe("openai"); + expect(call?.model).toBe("gpt-5.2"); + }); + }); + + it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: HEARTBEAT_TOKEN }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + }); + }); + + it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + + expect(maybeReplyText(res)).toBe("hello"); + }); + }); + + it("updates group activation when the owner sends /activation", async () => { + await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + const cfg = makeCfg(home); + const res = await getReplyFromConfig( + { + Body: "/activation always", + From: "123@g.us", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + SenderE164: "+2000", + CommandAuthorized: true, + }, + {}, + cfg, + ); + expect(maybeReplyText(res)).toContain("Group activation set to always"); + const store = JSON.parse(await fs.readFile(requireSessionStorePath(cfg), "utf-8")) as Record< + string, + { groupActivation?: string } + >; + expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + + it("runs /compact as a gated command", async () => { + await withTempHome(async (home) => { + const storePath = join(tmpdir(), `openclaw-session-test-${Date.now()}.json`); + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: storePath }; + mockSuccessfulCompaction(); + + const request = { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + }; + + const res = await getReplyFromConfig( + { + ...request, + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = maybeReplyText(res); + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + const store = loadSessionStore(storePath); + const sessionKey = resolveSessionKey("per-sender", request); + expect(store[sessionKey]?.compactionCount).toBe(1); + }); + }); + + it("runs /compact for non-default agents without transcript path validation failures", async () => { + await withTempHome(async (home) => { + getCompactEmbeddedPiSessionMock().mockClear(); + mockSuccessfulCompaction(); + + const res = await getReplyFromConfig( + { + Body: "/compact", + From: "+1004", + To: "+2000", + SessionKey: "agent:worker1:telegram:12345", + CommandAuthorized: true, + }, + {}, + makeCfg(home), + ); + + const text = maybeReplyText(res); + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain( + join("agents", "worker1", "sessions"), + ); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + }); + }); + + it("ignores think directives that only appear in the context wrapper", async () => { + await withTempHome(async (home) => { + mockRunEmbeddedPiAgentOk(); + + const res = await getReplyFromConfig( + { + Body: [ + "[Chat messages since your last reply - for context]", + "Peter: /thinking high [2025-12-05T21:45:00.000Z]", + "", + "[Current message - respond to this]", + "Give me the status", + ].join("\n"), + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + + expect(maybeReplyText(res)).toBe("ok"); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); + const prompt = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain("Give me the status"); + expect(prompt).not.toContain("/thinking high"); + expect(prompt).not.toContain("/think high"); + }); + }); + + it("does not emit directive acks for heartbeats with /think", async () => { + await withTempHome(async (home) => { + mockRunEmbeddedPiAgentOk(); + + const res = await getReplyFromConfig( + { + Body: "HEARTBEAT /think:high", + From: "+1003", + To: "+1003", + }, + { isHeartbeat: true }, + makeCfg(home), + ); + + const text = maybeReplyText(res); + expect(text).toBe("ok"); + expect(text).not.toMatch(/Thinking level set/i); + expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); }); }); From 42795b87a36e4a863b1bd916f698e238346f2daf Mon Sep 17 00:00:00 2001 From: Kay-051 Date: Mon, 23 Feb 2026 16:53:49 +0800 Subject: [PATCH 015/314] fix(agents): don't auto-enable reasoning when thinking is active (#24290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When thinking is set (e.g. thinking=low), the model produces internal thinking blocks. The reasoning auto-default (based on model capability) was formatting these blocks as "Reasoning:" text and delivering them to WhatsApp/Telegram, leaking internal content to users. Skip auto-enabling reasoning when thinkLevel is already set — the two features serve the same purpose and enabling both causes the model's internal thinking to be exposed as visible chat messages. Users who explicitly set /reasoning on still get reasoning output. Closes #24290 Co-authored-by: Cursor --- src/auto-reply/reply/get-reply-directives.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index f421ed92eae..ba1b1c0656e 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -390,10 +390,14 @@ export async function resolveReplyDirectives(params: { model = modelState.model; // When neither directive nor session set reasoning, default to model capability (e.g. OpenRouter with reasoning: true). + // Skip auto-enabling when thinking is already active — the model's internal + // thinking blocks would otherwise be formatted and delivered as visible + // "Reasoning:" messages, leaking internal content to the user. const reasoningExplicitlySet = directives.reasoningLevel !== undefined || (sessionEntry?.reasoningLevel !== undefined && sessionEntry?.reasoningLevel !== null); - if (!reasoningExplicitlySet && resolvedReasoningLevel === "off") { + const thinkingActive = resolvedThinkLevel !== undefined && resolvedThinkLevel !== "off"; + if (!reasoningExplicitlySet && resolvedReasoningLevel === "off" && !thinkingActive) { resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel(); } From 9d37654a90571555ead7d4e092daace65d4b1d1f Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 23 Feb 2026 14:58:26 +0200 Subject: [PATCH 016/314] fix(agents): gate auto reasoning by effective thinking level (openclaw#24335) thanks @Kay-051 --- CHANGELOG.md | 1 + ...nk-low-reasoning-capable-models-no.test.ts | 34 ++++++++++++++++++- src/auto-reply/reply/get-reply-directives.ts | 12 ++++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4191591ad81..3046ef56cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. - Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. - Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index dc78e7ac553..5dc9819c4cd 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -244,7 +244,7 @@ describe("directive behavior", () => { expect(call?.model).toBe("claude-opus-4-5"); }); }); - it("defaults thinking to low for reasoning-capable models during normal replies", async () => { + it("defaults thinking to low for reasoning-capable models without auto-enabling reasoning", async () => { await withTempHome(async (home) => { mockEmbeddedTextResult("done"); vi.mocked(loadModelCatalog).mockResolvedValueOnce([ @@ -269,6 +269,38 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; expect(call?.thinkLevel).toBe("low"); + expect(call?.reasoningLevel).toBe("off"); + }); + }); + it("keeps auto-reasoning enabled when thinking is explicitly off", async () => { + await withTempHome(async (home) => { + mockEmbeddedTextResult("done"); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + reasoning: true, + }, + ]); + + await getReplyFromConfig( + { + Body: "hello", + From: "+1004", + To: "+2000", + }, + {}, + makeWhatsAppDirectiveConfig(home, { + model: { primary: "anthropic/claude-opus-4-5" }, + thinkingDefault: "off", + }), + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.thinkLevel).toBe("off"); + expect(call?.reasoningLevel).toBe("on"); }); }); it("passes elevated defaults when sender is approved", async () => { diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index ba1b1c0656e..6129dd419cb 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -389,14 +389,16 @@ export async function resolveReplyDirectives(params: { provider = modelState.provider; model = modelState.model; - // When neither directive nor session set reasoning, default to model capability (e.g. OpenRouter with reasoning: true). - // Skip auto-enabling when thinking is already active — the model's internal - // thinking blocks would otherwise be formatted and delivered as visible - // "Reasoning:" messages, leaking internal content to the user. + // When neither directive nor session set reasoning, default to model capability + // (e.g. OpenRouter with reasoning: true). Skip auto-enabling when thinking is + // active, including model-inferred defaults, or internal thinking blocks can + // be emitted as visible "Reasoning:" messages. const reasoningExplicitlySet = directives.reasoningLevel !== undefined || (sessionEntry?.reasoningLevel !== undefined && sessionEntry?.reasoningLevel !== null); - const thinkingActive = resolvedThinkLevel !== undefined && resolvedThinkLevel !== "off"; + const effectiveThinkingForReasoning = + resolvedThinkLevel ?? (await modelState.resolveDefaultThinkingLevel()); + const thinkingActive = effectiveThinkingForReasoning !== "off"; if (!reasoningExplicitlySet && resolvedReasoningLevel === "off" && !thinkingActive) { resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel(); } From 5196565f197ee1946d647b432af46f08b1f98a64 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:41:33 +0000 Subject: [PATCH 017/314] test: reduce trigger test redundancy and speed up model coverage --- ...ne-commands-strips-it-before-agent.test.ts | 84 ++++++++---------- ...targets-active-session-native-stop.test.ts | 87 ------------------- ....triggers.trigger-handling.test-harness.ts | 25 ++---- .../reply/directive-handling.model.test.ts | 69 +++++++++++++++ 4 files changed, 113 insertions(+), 152 deletions(-) diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index 6072608aeb7..45fda184b47 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { expectInlineCommandHandledAndStripped, - expectDirectElevatedToggleOn, getRunEmbeddedPiAgentMock, installTriggerHandlingE2eTestHooks, loadGetReplyFromConfig, @@ -178,15 +177,11 @@ describe("trigger handling", () => { }); }); - it("runs a greeting prompt for a bare /reset", async () => { + it("runs a greeting prompt for bare /reset and /new", async () => { await withTempHome(async (home) => { - await runGreetingPromptForBareNewOrReset({ home, body: "/reset", getReplyFromConfig }); - }); - }); - - it("runs a greeting prompt for a bare /new", async () => { - await withTempHome(async (home) => { - await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig }); + for (const body of ["/reset", "/new"] as const) { + await runGreetingPromptForBareNewOrReset({ home, body, getReplyFromConfig }); + } }); }); @@ -201,42 +196,41 @@ describe("trigger handling", () => { }); }); - it("handles inline /commands and strips it before the agent", async () => { + it("handles inline help/whoami/commands and strips directives before the agent", async () => { await withTempHome(async (home) => { - await expectInlineCommandHandledAndStripped({ - home, - getReplyFromConfig, - body: "please /commands now", - stripToken: "/commands", - blockReplyContains: "Slash commands", - }); - }); - }); - - it("handles inline /whoami and strips it before the agent", async () => { - await withTempHome(async (home) => { - await expectInlineCommandHandledAndStripped({ - home, - getReplyFromConfig, - body: "please /whoami now", - stripToken: "/whoami", - blockReplyContains: "Identity", - requestOverrides: { - SenderId: "12345", + const cases: Array<{ + body: string; + stripToken: string; + blockReplyContains: string; + requestOverrides?: Record; + }> = [ + { + body: "please /commands now", + stripToken: "/commands", + blockReplyContains: "Slash commands", }, - }); - }); - }); - - it("handles inline /help and strips it before the agent", async () => { - await withTempHome(async (home) => { - await expectInlineCommandHandledAndStripped({ - home, - getReplyFromConfig, - body: "please /help now", - stripToken: "/help", - blockReplyContains: "Help", - }); + { + body: "please /whoami now", + stripToken: "/whoami", + blockReplyContains: "Identity", + requestOverrides: { SenderId: "12345" }, + }, + { + body: "please /help now", + stripToken: "/help", + blockReplyContains: "Help", + }, + ]; + for (const testCase of cases) { + await expectInlineCommandHandledAndStripped({ + home, + getReplyFromConfig, + body: testCase.body, + stripToken: testCase.stripToken, + blockReplyContains: testCase.blockReplyContains, + requestOverrides: testCase.requestOverrides, + }); + } }); }); @@ -311,10 +305,6 @@ describe("trigger handling", () => { }); }); - it("allows approved sender to toggle elevated mode", async () => { - await expectDirectElevatedToggleOn({ getReplyFromConfig }); - }); - it("rejects elevated toggles when disabled", async () => { await withTempHome(async (home) => { const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts index 61b9be19d43..4c40064b269 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { @@ -36,42 +35,12 @@ afterAll(() => { installTriggerHandlingE2eTestHooks(); -const DEFAULT_SESSION_KEY = "telegram:slash:111"; const BASE_MESSAGE = { Body: "hello", From: "+1002", To: "+2000", } as const; -function makeTelegramModelCommand(body: string, sessionKey = DEFAULT_SESSION_KEY) { - return { - Body: body, - From: "telegram:111", - To: "telegram:111", - ChatType: "direct" as const, - Provider: "telegram" as const, - Surface: "telegram" as const, - SessionKey: sessionKey, - CommandAuthorized: true, - }; -} - -function firstReplyText(reply: Awaited>) { - return Array.isArray(reply) ? (reply[0]?.text ?? "") : (reply?.text ?? ""); -} - -async function runModelCommand(home: string, body: string, sessionKey = DEFAULT_SESSION_KEY) { - const cfg = makeCfg(home); - const res = await getReplyFromConfig(makeTelegramModelCommand(body, sessionKey), {}, cfg); - const text = firstReplyText(res); - return { - cfg, - sessionKey, - text, - normalized: normalizeTestText(text), - }; -} - function maybeReplyText(reply: Awaited>) { return Array.isArray(reply) ? reply[0]?.text : reply?.text; } @@ -326,62 +295,6 @@ describe("trigger handling", () => { }); }); - it("selects the exact provider/model pair for openrouter", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand( - home, - "/model openrouter/anthropic/claude-opus-4-5", - ); - - expect(normalized).toContain("Model set to openrouter/anthropic/claude-opus-4-5"); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBe("openrouter"); - expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5"); - }); - }); - - it("rejects invalid /model <#> selections", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand(home, "/model 99"); - - expect(normalized).toContain("Numeric model selection is not supported in chat."); - expect(normalized).toContain("Browse: /models or /models "); - expect(normalized).toContain("Switch: /model "); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBeUndefined(); - expect(store[sessionKey]?.modelOverride).toBeUndefined(); - }); - }); - - it("resets to the default model via /model ", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand( - home, - "/model anthropic/claude-opus-4-5", - ); - - expect(normalized).toContain("Model reset to default (anthropic/claude-opus-4-5)"); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBeUndefined(); - expect(store[sessionKey]?.modelOverride).toBeUndefined(); - }); - }); - - it("selects a model via /model ", async () => { - await withTempHome(async (home) => { - const { cfg, sessionKey, normalized } = await runModelCommand(home, "/model openai/gpt-5.2"); - - expect(normalized).toContain("Model set to openai/gpt-5.2"); - - const store = loadSessionStore(requireSessionStorePath(cfg)); - expect(store[sessionKey]?.providerOverride).toBe("openai"); - expect(store[sessionKey]?.modelOverride).toBe("gpt-5.2"); - }); - }); - it("targets the active session for native /stop", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index a0d538a501b..2d567de6ea8 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -301,20 +301,6 @@ export async function runDirectElevatedToggleAndLoadStore(params: { return { text, store }; } -export async function expectDirectElevatedToggleOn(params: { - getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; -}) { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home); - const { text, store } = await runDirectElevatedToggleAndLoadStore({ - cfg, - getReplyFromConfig: params.getReplyFromConfig, - }); - expect(text).toContain("Elevated mode set to ask"); - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); - }); -} - export async function expectInlineCommandHandledAndStripped(params: { home: string; getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; @@ -324,6 +310,7 @@ export async function expectInlineCommandHandledAndStripped(params: { requestOverrides?: Record; }) { const runEmbeddedPiAgentMock = mockRunEmbeddedPiAgentOk(); + runEmbeddedPiAgentMock.mockClear(); const { blockReplies, handlers } = createBlockReplyCollector(); const res = await params.getReplyFromConfig( { @@ -341,7 +328,7 @@ export async function expectInlineCommandHandledAndStripped(params: { expect(blockReplies.length).toBe(1); expect(blockReplies[0]?.text).toContain(params.blockReplyContains); expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); - const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; + const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? ""; expect(prompt).not.toContain(params.stripToken); expect(text).toBe("ok"); } @@ -351,7 +338,9 @@ export async function runGreetingPromptForBareNewOrReset(params: { body: "/new" | "/reset"; getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; }) { - getRunEmbeddedPiAgentMock().mockResolvedValue({ + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockClear(); + runEmbeddedPiAgentMock.mockResolvedValue({ payloads: [{ text: "hello" }], meta: { durationMs: 1, @@ -371,8 +360,8 @@ export async function runGreetingPromptForBareNewOrReset(params: { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("hello"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); - const prompt = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.prompt ?? ""; + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + const prompt = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.prompt ?? ""; expect(prompt).toContain("A new session was started via /new or /reset"); expect(prompt).toContain("Execute your Session Startup sequence now"); } diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 9e47d5dffcc..d8e198184e0 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -112,6 +112,75 @@ describe("/model chat UX", () => { }); expect(resolved.errorText).toBeUndefined(); }); + + it("rejects numeric /model selections with a guided error", () => { + const directives = parseInlineDirectives("/model 99"); + const cfg = { commands: { text: true } } as unknown as OpenClawConfig; + + const resolved = resolveModelSelectionFromDirective({ + directives, + cfg, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]), + allowedModelCatalog: [], + provider: "anthropic", + }); + + expect(resolved.modelSelection).toBeUndefined(); + expect(resolved.errorText).toContain("Numeric model selection is not supported in chat."); + expect(resolved.errorText).toContain("Browse: /models or /models "); + }); + + it("treats explicit default /model selection as resettable default", () => { + const directives = parseInlineDirectives("/model anthropic/claude-opus-4-5"); + const cfg = { commands: { text: true } } as unknown as OpenClawConfig; + + const resolved = resolveModelSelectionFromDirective({ + directives, + cfg, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]), + allowedModelCatalog: [], + provider: "anthropic", + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "anthropic", + model: "claude-opus-4-5", + isDefault: true, + }); + }); + + it("keeps openrouter provider/model split for exact selections", () => { + const directives = parseInlineDirectives("/model openrouter/anthropic/claude-opus-4-5"); + const cfg = { commands: { text: true } } as unknown as OpenClawConfig; + + const resolved = resolveModelSelectionFromDirective({ + directives, + cfg, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openrouter/anthropic/claude-opus-4-5"]), + allowedModelCatalog: [], + provider: "anthropic", + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openrouter", + model: "anthropic/claude-opus-4-5", + isDefault: false, + }); + }); }); describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { From 3f03cdea56ab7a486664e47d98695cc24a681845 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 13:57:34 +0000 Subject: [PATCH 018/314] test: optimize redundant suites for faster runtime --- extensions/msteams/src/attachments.test.ts | 4 + ...ne-commands-strips-it-before-agent.test.ts | 300 +++++++++--------- src/gateway/openresponses-http.ts | 72 +---- src/gateway/openresponses-parity.test.ts | 4 +- src/gateway/openresponses-prompt.ts | 70 ++++ ...s-media-file-path-no-file-download.test.ts | 59 ++++ ...udes-location-text-ctx-fields-pins.test.ts | 88 ----- 7 files changed, 288 insertions(+), 309 deletions(-) create mode 100644 src/gateway/openresponses-prompt.ts delete mode 100644 src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index f33541cb8d3..42470e370b6 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -2,6 +2,10 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setMSTeamsRuntime } from "./runtime.js"; +vi.mock("openclaw/plugin-sdk", () => ({ + isPrivateIpAddress: () => false, +})); + /** Mock DNS resolver that always returns a public IP (for anti-SSRF validation in tests). */ const publicResolveFn = async () => ({ address: "13.107.136.10" }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index 45fda184b47..ff9521c9799 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -234,16 +234,11 @@ describe("trigger handling", () => { }); }); - it("drops top-level restricted commands for unauthorized senders", async () => { + it("enforces top-level command auth but keeps inline text for unauthorized senders", async () => { await withTempHome(async (home) => { for (const command of ["/status", "/whoami"] as const) { await expectUnauthorizedCommandDropped(home, command); } - }); - }); - - it("keeps inline commands for unauthorized senders", async () => { - await withTempHome(async (home) => { for (const command of ["/status", "/help"] as const) { const runEmbeddedPiAgentMock = mockEmbeddedOk(); const res = await runInlineUnauthorizedCommand({ @@ -305,109 +300,115 @@ describe("trigger handling", () => { }); }); - it("rejects elevated toggles when disabled", async () => { + it("enforces elevated toggles across enabled and mention scenarios", async () => { await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }); + const isolateStore = (cfg: ReturnType, label: string) => { + cfg.session = { ...cfg.session, store: join(home, `${label}.sessions.json`) }; + return cfg; + }; - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("tools.elevated.enabled"); + { + const cfg = isolateStore(makeWhatsAppElevatedCfg(home, { elevatedEnabled: false }), "off"); + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("tools.elevated.enabled"); - const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); - const store = JSON.parse(storeRaw) as Record; - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); - }); - }); + const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); + const store = JSON.parse(storeRaw) as Record; + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined(); + } - it("allows elevated off in groups without mention", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); + { + const cfg = isolateStore( + makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }), + "group-off", + ); + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + const store = await readSessionStore(cfg); + expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off"); + } - const res = await getReplyFromConfig( - { - Body: "/elevated off", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - ChatType: "group", - WasMentioned: false, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode disabled."); + { + const cfg = isolateStore( + makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }), + "group-on", + ); + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + ChatType: "group", + WasMentioned: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode set to ask"); + const store = await readSessionStore(cfg); + expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on"); + } - const store = await readSessionStore(cfg); - expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("off"); - }); - }); - - it("allows elevated directive in groups when mentioned", async () => { - await withTempHome(async (home) => { - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: true }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - ChatType: "group", - WasMentioned: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode set to ask"); - - const store = await readSessionStore(cfg); - expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe("on"); - }); - }); - - it("ignores elevated directive in groups when not mentioned", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }); - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "whatsapp:group:123@g.us", - To: "whatsapp:+2000", - Provider: "whatsapp", - SenderE164: "+1000", - ChatType: "group", - WasMentioned: false, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBeUndefined(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + { + const cfg = isolateStore( + makeWhatsAppElevatedCfg(home, { requireMentionInGroups: false }), + "group-ignore", + ); + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockClear(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "whatsapp:group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBeUndefined(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + } }); }); @@ -439,56 +440,57 @@ describe("trigger handling", () => { }); }); - it("uses tools.elevated.allowFrom.discord for elevated approval", async () => { + it("handles discord elevated allowlist and override behavior", async () => { await withTempHome(async (home) => { - const cfg = makeCfg(home); - cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } }; + { + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: join(home, "discord-allow.sessions.json") }; + cfg.tools = { elevated: { allowFrom: { discord: ["123"] } } }; - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "discord:123", - To: "user:123", - Provider: "discord", - SenderName: "Peter Steinberger", - SenderUsername: "steipete", - SenderTag: "steipete", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Elevated mode set to ask"); + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "discord:123", + To: "user:123", + Provider: "discord", + SenderName: "Peter Steinberger", + SenderUsername: "steipete", + SenderTag: "steipete", + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode set to ask"); + const store = await readSessionStore(cfg); + expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); + } - const store = await readSessionStore(cfg); - expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on"); - }); - }); + { + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: join(home, "discord-deny.sessions.json") }; + cfg.tools = { + elevated: { + allowFrom: { discord: [] }, + }, + }; - it("treats explicit discord elevated allowlist as override", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - cfg.tools = { - elevated: { - allowFrom: { discord: [] }, - }, - }; - - const res = await getReplyFromConfig( - { - Body: "/elevated on", - From: "discord:123", - To: "user:123", - Provider: "discord", - SenderName: "steipete", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("tools.elevated.allowFrom.discord"); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "discord:123", + To: "user:123", + Provider: "discord", + SenderName: "steipete", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("tools.elevated.allowFrom.discord"); + expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + } }); }); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 791fdb5e68f..ab1a4a5e0d0 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -30,10 +30,6 @@ import { } from "../media/input-files.js"; import { defaultRuntime } from "../runtime.js"; import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js"; -import { - buildAgentMessageFromConversationEntries, - type ConversationEntry, -} from "./agent-prompt.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; @@ -41,14 +37,13 @@ import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { resolveAgentIdForRequest, resolveSessionKey } from "./http-utils.js"; import { CreateResponseBodySchema, - type ContentPart, type CreateResponseBody, - type ItemParam, type OutputItem, type ResponseResource, type StreamingEvent, type Usage, } from "./open-responses.schema.js"; +import { buildAgentPrompt } from "./openresponses-prompt.js"; type OpenResponsesHttpOptions = { auth: ResolvedGatewayAuth; @@ -67,24 +62,6 @@ function writeSseEvent(res: ServerResponse, event: StreamingEvent) { res.write(`data: ${JSON.stringify(event)}\n\n`); } -function extractTextContent(content: string | ContentPart[]): string { - if (typeof content === "string") { - return content; - } - return content - .map((part) => { - if (part.type === "input_text") { - return part.text; - } - if (part.type === "output_text") { - return part.text; - } - return ""; - }) - .filter(Boolean) - .join("\n"); -} - type ResolvedResponsesLimits = { maxBodyBytes: number; maxUrlParts: number; @@ -172,52 +149,7 @@ function applyToolChoice(params: { return { tools }; } -export function buildAgentPrompt(input: string | ItemParam[]): { - message: string; - extraSystemPrompt?: string; -} { - if (typeof input === "string") { - return { message: input }; - } - - const systemParts: string[] = []; - const conversationEntries: ConversationEntry[] = []; - - for (const item of input) { - if (item.type === "message") { - const content = extractTextContent(item.content).trim(); - if (!content) { - continue; - } - - if (item.role === "system" || item.role === "developer") { - systemParts.push(content); - continue; - } - - const normalizedRole = item.role === "assistant" ? "assistant" : "user"; - const sender = normalizedRole === "assistant" ? "Assistant" : "User"; - - conversationEntries.push({ - role: normalizedRole, - entry: { sender, body: content }, - }); - } else if (item.type === "function_call_output") { - conversationEntries.push({ - role: "tool", - entry: { sender: `Tool:${item.call_id}`, body: item.output }, - }); - } - // Skip reasoning and item_reference for prompt building (Phase 1) - } - - const message = buildAgentMessageFromConversationEntries(conversationEntries); - - return { - message, - extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, - }; -} +export { buildAgentPrompt } from "./openresponses-prompt.js"; function resolveOpenResponsesSessionKey(params: { req: IncomingMessage; diff --git a/src/gateway/openresponses-parity.test.ts b/src/gateway/openresponses-parity.test.ts index 1f4212ab0a6..3e4b2dc535b 100644 --- a/src/gateway/openresponses-parity.test.ts +++ b/src/gateway/openresponses-parity.test.ts @@ -12,7 +12,7 @@ let InputFileContentPartSchema: typeof import("./open-responses.schema.js").Inpu let ToolDefinitionSchema: typeof import("./open-responses.schema.js").ToolDefinitionSchema; let CreateResponseBodySchema: typeof import("./open-responses.schema.js").CreateResponseBodySchema; let OutputItemSchema: typeof import("./open-responses.schema.js").OutputItemSchema; -let buildAgentPrompt: typeof import("./openresponses-http.js").buildAgentPrompt; +let buildAgentPrompt: typeof import("./openresponses-prompt.js").buildAgentPrompt; describe("OpenResponses Feature Parity", () => { beforeAll(async () => { @@ -23,7 +23,7 @@ describe("OpenResponses Feature Parity", () => { CreateResponseBodySchema, OutputItemSchema, } = await import("./open-responses.schema.js")); - ({ buildAgentPrompt } = await import("./openresponses-http.js")); + ({ buildAgentPrompt } = await import("./openresponses-prompt.js")); }); describe("Schema Validation", () => { diff --git a/src/gateway/openresponses-prompt.ts b/src/gateway/openresponses-prompt.ts new file mode 100644 index 00000000000..fad2d4787e8 --- /dev/null +++ b/src/gateway/openresponses-prompt.ts @@ -0,0 +1,70 @@ +import { + buildAgentMessageFromConversationEntries, + type ConversationEntry, +} from "./agent-prompt.js"; +import type { ContentPart, ItemParam } from "./open-responses.schema.js"; + +function extractTextContent(content: string | ContentPart[]): string { + if (typeof content === "string") { + return content; + } + return content + .map((part) => { + if (part.type === "input_text") { + return part.text; + } + if (part.type === "output_text") { + return part.text; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +export function buildAgentPrompt(input: string | ItemParam[]): { + message: string; + extraSystemPrompt?: string; +} { + if (typeof input === "string") { + return { message: input }; + } + + const systemParts: string[] = []; + const conversationEntries: ConversationEntry[] = []; + + for (const item of input) { + if (item.type === "message") { + const content = extractTextContent(item.content).trim(); + if (!content) { + continue; + } + + if (item.role === "system" || item.role === "developer") { + systemParts.push(content); + continue; + } + + const normalizedRole = item.role === "assistant" ? "assistant" : "user"; + const sender = normalizedRole === "assistant" ? "Assistant" : "User"; + + conversationEntries.push({ + role: normalizedRole, + entry: { sender, body: content }, + }); + } else if (item.type === "function_call_output") { + conversationEntries.push({ + role: "tool", + entry: { sender: `Tool:${item.call_id}`, body: item.output }, + }); + } + // Skip reasoning and item_reference for prompt building (Phase 1) + } + + const message = buildAgentMessageFromConversationEntries(conversationEntries); + + return { + message, + extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, + }; +} diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 88e9f29590e..47448cd0a6d 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -209,6 +209,65 @@ describe("telegram inbound media", () => { fetchSpy.mockRestore(); }); + + it("captures pin and venue location payload fields", async () => { + const { handler, replySpy } = await createBotHandler(); + + const cases = [ + { + message: { + chat: { id: 42, type: "private" as const }, + message_id: 5, + caption: "Meet here", + date: 1736380800, + location: { + latitude: 48.858844, + longitude: 2.294351, + horizontal_accuracy: 12, + }, + }, + assert: (payload: Record) => { + expect(payload.Body).toContain("Meet here"); + expect(payload.Body).toContain("48.858844"); + expect(payload.LocationLat).toBe(48.858844); + expect(payload.LocationLon).toBe(2.294351); + expect(payload.LocationSource).toBe("pin"); + expect(payload.LocationIsLive).toBe(false); + }, + }, + { + message: { + chat: { id: 42, type: "private" as const }, + message_id: 6, + date: 1736380800, + venue: { + title: "Eiffel Tower", + address: "Champ de Mars, Paris", + location: { latitude: 48.858844, longitude: 2.294351 }, + }, + }, + assert: (payload: Record) => { + expect(payload.Body).toContain("Eiffel Tower"); + expect(payload.LocationName).toBe("Eiffel Tower"); + expect(payload.LocationAddress).toBe("Champ de Mars, Paris"); + expect(payload.LocationSource).toBe("place"); + }, + }, + ] as const; + + for (const testCase of cases) { + replySpy.mockClear(); + await handler({ + message: testCase.message, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "unused" }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0] as Record; + testCase.assert(payload); + } + }); }); describe("telegram media groups", () => { diff --git a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts deleted file mode 100644 index 2bc104fba34..00000000000 --- a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { onSpy } from "./bot.media.e2e-harness.js"; - -let handler: (ctx: Record) => Promise; -let replySpy: ReturnType; - -beforeAll(async () => { - const { createTelegramBot } = await import("./bot.js"); - const replyModule = await import("../auto-reply/reply.js"); - replySpy = (replyModule as unknown as { __replySpy: ReturnType }).__replySpy; - - onSpy.mockClear(); - createTelegramBot({ token: "tok" }); - const registeredHandler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as ( - ctx: Record, - ) => Promise; - expect(registeredHandler).toBeDefined(); - handler = registeredHandler; -}); - -beforeEach(() => { - replySpy.mockClear(); -}); - -function expectSingleReplyPayload(replySpy: ReturnType) { - expect(replySpy).toHaveBeenCalledTimes(1); - return replySpy.mock.calls[0][0] as Record; -} - -describe("telegram inbound media", () => { - const _INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 20_000; - it( - "includes location text and ctx fields for pins", - async () => { - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 5, - caption: "Meet here", - date: 1736380800, - location: { - latitude: 48.858844, - longitude: 2.294351, - horizontal_accuracy: 12, - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: "unused" }), - }); - - const payload = expectSingleReplyPayload(replySpy); - expect(payload.Body).toContain("Meet here"); - expect(payload.Body).toContain("48.858844"); - expect(payload.LocationLat).toBe(48.858844); - expect(payload.LocationLon).toBe(2.294351); - expect(payload.LocationSource).toBe("pin"); - expect(payload.LocationIsLive).toBe(false); - }, - _INBOUND_MEDIA_TEST_TIMEOUT_MS, - ); - - it( - "captures venue fields for named places", - async () => { - await handler({ - message: { - chat: { id: 42, type: "private" }, - message_id: 6, - date: 1736380800, - venue: { - title: "Eiffel Tower", - address: "Champ de Mars, Paris", - location: { latitude: 48.858844, longitude: 2.294351 }, - }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ file_path: "unused" }), - }); - - const payload = expectSingleReplyPayload(replySpy); - expect(payload.Body).toContain("Eiffel Tower"); - expect(payload.LocationName).toBe("Eiffel Tower"); - expect(payload.LocationAddress).toBe("Champ de Mars, Paris"); - expect(payload.LocationSource).toBe("place"); - }, - _INBOUND_MEDIA_TEST_TIMEOUT_MS, - ); -}); From 3a3c2da9168f93397eeb3109d521819e10dc44fd Mon Sep 17 00:00:00 2001 From: AkosCz Date: Mon, 23 Feb 2026 08:30:51 -0600 Subject: [PATCH 019/314] [Feature]: Add Gemini (Google Search grounding) as web_search provider (#13075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Gemini (Google Search grounding) as web_search provider Add Gemini as a fourth web search provider alongside Brave, Perplexity, and Grok. Uses Gemini's built-in Google Search grounding tool to return search results with citations. - Add runGeminiSearch() with Google Search grounding via tools API - Resolve Gemini's grounding redirect URLs to direct URLs via parallel HEAD requests (5s timeout, graceful fallback) - Add Gemini config block (apiKey, model) with env var fallback - Default model: gemini-2.5-flash (fast, cheap, grounding-capable) - Strip API key from error messages for security - Add config validation tests for Gemini provider - Update docs/tools/web.md with Gemini provider documentation Closes #13074 * feat: auto-detect search provider from available API keys When no explicit provider is configured, resolveSearchProvider now checks for available API keys in priority order (Brave → Gemini → Perplexity → Grok) and selects the first provider with a valid key. - Add auto-detection logic using existing resolve*ApiKey functions - Export resolveSearchProvider via __testing_provider for tests - Add 8 tests covering auto-detection, priority order, and explicit override - Update docs/tools/web.md with auto-detection documentation * fix: merge __testing exports, downgrade auto-detect log to debug * fix: use defaultRuntime.log instead of .debug (not in RuntimeEnv type) * fix: mark gemini apiKey as sensitive in zod schema * fix: address Greptile review — add externalContent to Gemini payload, add Gemini/Grok entries to schema labels/help, remove dead schema-fields.ts * fix(web-search): add JSON parse guard for Gemini API responses Addresses Greptile review comment: add try/catch to handle non-JSON responses from Gemini API gracefully, preventing runtime errors on malformed responses. Note: FIELD_HELP entries for gemini.apiKey and gemini.model were already present in schema.help.ts, and gemini.apiKey was already marked as sensitive in zod-schema.agent-runtime.ts (both fixed in earlier commits). * fix: use structured readResponseText result in Gemini error path readResponseText returns { text, truncated, bytesRead }, not a string. The Gemini error handler was using the result object directly, which would always be truthy and never fall through to res.statusText. Align with Perplexity/xAI/Brave error patterns. Co-Authored-By: Claude Opus 4.6 * style: fix import order and formatting after rebase onto main * Web search: send Gemini API key via header --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + docs/tools/web.md | 63 ++++- src/agents/tools/web-search.ts | 252 +++++++++++++++++- src/config/config.web-search-provider.test.ts | 127 +++++++++ src/config/schema.help.ts | 8 +- src/config/schema.labels.ts | 4 + src/config/types.tools.ts | 11 +- src/config/zod-schema.agent-runtime.ts | 11 +- 8 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 src/config/config.web-search-provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3046ef56cb7..5cd222ca52e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai ### Changes - Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. - Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. diff --git a/docs/tools/web.md b/docs/tools/web.md index b0e295cd22a..85093ad62bd 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,9 +1,10 @@ --- -summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter)" +summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)" read_when: - You want to enable web_search or web_fetch - You need Brave Search API key setup - You want to use Perplexity Sonar for web search + - You want to use Gemini with Google Search grounding title: "Web Tools" --- @@ -11,7 +12,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter). +- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -22,6 +23,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the - `web_search` calls your configured provider and returns results. - **Brave** (default): returns structured results (title, URL, snippet). - **Perplexity**: returns AI-synthesized answers with citations from real-time web search. + - **Gemini**: returns AI-synthesized answers grounded in Google Search with citations. - Results are cached by query for 15 minutes (configurable). - `web_fetch` does a plain HTTP GET and extracts readable content (HTML → markdown/text). It does **not** execute JavaScript. @@ -33,9 +35,23 @@ These are **not** browser automation. For JS-heavy sites or logins, use the | ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | | **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` | | **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` | +| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` | See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details. +### Auto-detection + +If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order: + +1. **Brave** — `BRAVE_API_KEY` env var or `search.apiKey` config +2. **Gemini** — `GEMINI_API_KEY` env var or `search.gemini.apiKey` config +3. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config +4. **Grok** — `XAI_API_KEY` env var or `search.grok.apiKey` config + +If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). + +### Explicit provider + Set the provider in config: ```json5 @@ -43,7 +59,7 @@ Set the provider in config: tools: { web: { search: { - provider: "brave", // or "perplexity" + provider: "brave", // or "perplexity" or "gemini" }, }, }, @@ -139,6 +155,47 @@ If no base URL is set, OpenClaw chooses a default based on the API key source: | `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions | | `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research | +## Using Gemini (Google Search grounding) + +Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding), +which returns AI-synthesized answers backed by live Google Search results with citations. + +### Getting a Gemini API key + +1. Go to [Google AI Studio](https://aistudio.google.com/apikey) +2. Create an API key +3. Set `GEMINI_API_KEY` in the Gateway environment, or configure `tools.web.search.gemini.apiKey` + +### Setting up Gemini search + +```json5 +{ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + // API key (optional if GEMINI_API_KEY is set) + apiKey: "AIza...", + // Model (defaults to "gemini-2.5-flash") + model: "gemini-2.5-flash", + }, + }, + }, + }, +} +``` + +**Environment alternative:** set `GEMINI_API_KEY` in the Gateway environment. +For a gateway install, put it in `~/.openclaw/.env`. + +### Notes + +- Citation URLs from Gemini grounding are automatically resolved from Google's + redirect URLs to direct URLs. +- The default model (`gemini-2.5-flash`) is fast and cost-effective. + Any Gemini model that supports grounding can be used. + ## web_search Search the web using your configured provider. diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index c3a5d7692d0..83b0cece160 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,6 +1,7 @@ import { Type } from "@sinclair/typebox"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { defaultRuntime } from "../../runtime.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -18,7 +19,7 @@ import { writeCache, } from "./web-shared.js"; -const SEARCH_PROVIDERS = ["brave", "perplexity", "grok"] as const; +const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -183,6 +184,41 @@ function extractGrokContent(data: GrokSearchResponse): { return { text, annotationCitations: [] }; } +type GeminiConfig = { + apiKey?: string; + model?: string; +}; + +type GeminiGroundingResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + }; + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { + uri?: string; + title?: string; + }; + }>; + searchEntryPoint?: { + renderedContent?: string; + }; + webSearchQueries?: string[]; + }; + }>; + error?: { + code?: number; + message?: string; + status?: string; + }; +}; + +const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; +const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; + function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { const search = cfg?.tools?.web?.search; if (!search || typeof search !== "object") { @@ -227,6 +263,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { docs: "https://docs.openclaw.ai/tools/web", }; } + if (provider === "gemini") { + return { + error: "missing_gemini_api_key", + message: + "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } return { error: "missing_brave_api_key", message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, @@ -245,9 +289,49 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE if (raw === "grok") { return "grok"; } + if (raw === "gemini") { + return "gemini"; + } if (raw === "brave") { return "brave"; } + + // Auto-detect provider from available API keys (priority order) + if (raw === "") { + // 1. Brave + if (resolveSearchApiKey(search)) { + defaultRuntime.log( + 'web_search: no provider configured, auto-detected "brave" from available API keys', + ); + return "brave"; + } + // 2. Gemini + const geminiConfig = resolveGeminiConfig(search); + if (resolveGeminiApiKey(geminiConfig)) { + defaultRuntime.log( + 'web_search: no provider configured, auto-detected "gemini" from available API keys', + ); + return "gemini"; + } + // 3. Perplexity + const perplexityConfig = resolvePerplexityConfig(search); + const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); + if (perplexityKey) { + defaultRuntime.log( + 'web_search: no provider configured, auto-detected "perplexity" from available API keys', + ); + return "perplexity"; + } + // 4. Grok + const grokConfig = resolveGrokConfig(search); + if (resolveGrokApiKey(grokConfig)) { + defaultRuntime.log( + 'web_search: no provider configured, auto-detected "grok" from available API keys', + ); + return "grok"; + } + } + return "brave"; } @@ -389,6 +473,130 @@ function resolveGrokInlineCitations(grok?: GrokConfig): boolean { return grok?.inlineCitations === true; } +function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { + if (!search || typeof search !== "object") { + return {}; + } + const gemini = "gemini" in search ? search.gemini : undefined; + if (!gemini || typeof gemini !== "object") { + return {}; + } + return gemini as GeminiConfig; +} + +function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { + const fromConfig = normalizeApiKey(gemini?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY); + return fromEnv || undefined; +} + +function resolveGeminiModel(gemini?: GeminiConfig): string { + const fromConfig = + gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : ""; + return fromConfig || DEFAULT_GEMINI_MODEL; +} + +async function runGeminiSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { + const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": params.apiKey, + }, + body: JSON.stringify({ + contents: [ + { + parts: [{ text: params.query }], + }, + ], + tools: [{ google_search: {} }], + }), + signal: withTimeout(undefined, params.timeoutSeconds * 1000), + }); + + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + // Strip API key from any error detail to prevent accidental key leakage in logs + const safeDetail = (detailResult.text || res.statusText).replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); + } + + let data: GeminiGroundingResponse; + try { + data = (await res.json()) as GeminiGroundingResponse; + } catch (err) { + const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err }); + } + + if (data.error) { + const rawMsg = data.error.message || data.error.status || "unknown"; + const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***"); + throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`); + } + + const candidate = data.candidates?.[0]; + const content = + candidate?.content?.parts + ?.map((p) => p.text) + .filter(Boolean) + .join("\n") ?? "No response"; + + const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? []; + const rawCitations = groundingChunks + .filter((chunk) => chunk.web?.uri) + .map((chunk) => ({ + url: chunk.web!.uri!, + title: chunk.web?.title || undefined, + })); + + // Resolve Google grounding redirect URLs to direct URLs with concurrency cap. + // Gemini typically returns 3-8 citations; cap at 10 concurrent to be safe. + const MAX_CONCURRENT_REDIRECTS = 10; + const citations: Array<{ url: string; title?: string }> = []; + for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) { + const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS); + const resolved = await Promise.all( + batch.map(async (citation) => { + const resolvedUrl = await resolveRedirectUrl(citation.url); + return { ...citation, url: resolvedUrl }; + }), + ); + citations.push(...resolved); + } + + return { content, citations }; +} + +const REDIRECT_TIMEOUT_MS = 5000; + +/** + * Resolve a redirect URL to its final destination using a HEAD request. + * Returns the original URL if resolution fails or times out. + */ +async function resolveRedirectUrl(url: string): Promise { + try { + const res = await fetch(url, { + method: "HEAD", + redirect: "follow", + signal: withTimeout(undefined, REDIRECT_TIMEOUT_MS), + }); + return res.url || url; + } catch { + return url; + } +} + function resolveSearchCount(value: unknown, fallback: number): number { const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); @@ -590,13 +798,16 @@ async function runWebSearch(params: { perplexityModel?: string; grokModel?: string; grokInlineCitations?: boolean; + geminiModel?: string; }): Promise> { const cacheKey = normalizeCacheKey( params.provider === "brave" ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}` : params.provider === "perplexity" ? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.freshness || "default"}` - : `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`, + : params.provider === "gemini" + ? `${params.provider}:${params.query}:${params.geminiModel ?? DEFAULT_GEMINI_MODEL}` + : `${params.provider}:${params.query}:${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) { @@ -661,6 +872,32 @@ async function runWebSearch(params: { return payload; } + if (params.provider === "gemini") { + const geminiResult = await runGeminiSearch({ + query: params.query, + apiKey: params.apiKey, + model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, + timeoutSeconds: params.timeoutSeconds, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.geminiModel ?? DEFAULT_GEMINI_MODEL, + tookMs: Date.now() - start, // Includes redirect URL resolution time + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + content: wrapWebContent(geminiResult.content), + citations: geminiResult.citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + if (params.provider !== "brave") { throw new Error("Unsupported web search provider."); } @@ -741,13 +978,16 @@ export function createWebSearchTool(options?: { const provider = resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); const grokConfig = resolveGrokConfig(search); + const geminiConfig = resolveGeminiConfig(search); const description = provider === "perplexity" ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search." : provider === "grok" ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + : provider === "gemini" + ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; return { label: "Web Search", @@ -762,7 +1002,9 @@ export function createWebSearchTool(options?: { ? perplexityAuth?.apiKey : provider === "grok" ? resolveGrokApiKey(grokConfig) - : resolveSearchApiKey(search); + : provider === "gemini" + ? resolveGeminiApiKey(geminiConfig) + : resolveSearchApiKey(search); if (!apiKey) { return jsonResult(missingSearchKeyPayload(provider)); @@ -810,6 +1052,7 @@ export function createWebSearchTool(options?: { perplexityModel: resolvePerplexityModel(perplexityConfig), grokModel: resolveGrokModel(grokConfig), grokInlineCitations: resolveGrokInlineCitations(grokConfig), + geminiModel: resolveGeminiModel(geminiConfig), }); return jsonResult(result); }, @@ -817,6 +1060,7 @@ export function createWebSearchTool(options?: { } export const __testing = { + resolveSearchProvider, inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts new file mode 100644 index 00000000000..bc366ac8b48 --- /dev/null +++ b/src/config/config.web-search-provider.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { validateConfigObject } from "./config.js"; + +vi.mock("../runtime.js", () => ({ + defaultRuntime: { log: vi.fn(), error: vi.fn() }, +})); + +const { __testing } = await import("../agents/tools/web-search.js"); +const { resolveSearchProvider } = __testing; + +describe("web search provider config", () => { + it("accepts perplexity provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "perplexity", + perplexity: { + apiKey: "test-key", + baseUrl: "https://api.perplexity.ai", + model: "perplexity/sonar-pro", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts gemini provider and config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + enabled: true, + provider: "gemini", + gemini: { + apiKey: "test-key", + model: "gemini-2.5-flash", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts gemini provider with no extra config", () => { + const res = validateConfigObject({ + tools: { + web: { + search: { + provider: "gemini", + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); + +describe("web search provider auto-detection", () => { + const savedEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.BRAVE_API_KEY; + delete process.env.GEMINI_API_KEY; + delete process.env.PERPLEXITY_API_KEY; + delete process.env.OPENROUTER_API_KEY; + delete process.env.XAI_API_KEY; + }); + + afterEach(() => { + process.env = { ...savedEnv }; + vi.restoreAllMocks(); + }); + + it("falls back to brave when no keys available", () => { + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("auto-detects brave when only BRAVE_API_KEY is set", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("auto-detects gemini when only GEMINI_API_KEY is set", () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + expect(resolveSearchProvider({})).toBe("gemini"); + }); + + it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => { + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + expect(resolveSearchProvider({})).toBe("perplexity"); + }); + + it("auto-detects grok when only XAI_API_KEY is set", () => { + process.env.XAI_API_KEY = "test-xai-key"; + expect(resolveSearchProvider({})).toBe("grok"); + }); + + it("follows priority order — brave wins when multiple keys available", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.XAI_API_KEY = "test-xai-key"; + expect(resolveSearchProvider({})).toBe("brave"); + }); + + it("gemini wins over perplexity and grok when brave unavailable", () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + expect(resolveSearchProvider({})).toBe("gemini"); + }); + + it("explicit provider always wins regardless of keys", () => { + process.env.BRAVE_API_KEY = "test-brave-key"; + expect( + resolveSearchProvider({ provider: "gemini" } as unknown as Parameters< + typeof resolveSearchProvider + >[0]), + ).toBe("gemini"); + }); +}); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4aed9c674ce..3c0ea7d85e3 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -544,11 +544,17 @@ export const FIELD_HELP: Record = { 'Text suffix for cross-context markers (supports "{channel}").', "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", - "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.provider": + 'Search provider ("brave", "perplexity", "grok", or "gemini"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.gemini.apiKey": + "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).", + "tools.web.search.gemini.model": 'Gemini model override (default: "gemini-2.5-flash").', + "tools.web.search.grok.apiKey": "Grok (xAI) API key (fallback: XAI_API_KEY env var).", + "tools.web.search.grok.model": 'Grok model override (default: "grok-3").', "tools.web.search.perplexity.apiKey": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", "tools.web.search.perplexity.baseUrl": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 0f85a61d0b9..1891def7732 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -212,6 +212,10 @@ export const FIELD_LABELS: Record = { "tools.web.search.perplexity.apiKey": "Perplexity API Key", "tools.web.search.perplexity.baseUrl": "Perplexity Base URL", "tools.web.search.perplexity.model": "Perplexity Model", + "tools.web.search.gemini.apiKey": "Gemini Search API Key", + "tools.web.search.gemini.model": "Gemini Search Model", + "tools.web.search.grok.apiKey": "Grok Search API Key", + "tools.web.search.grok.model": "Grok Search Model", "tools.web.fetch.enabled": "Enable Web Fetch Tool", "tools.web.fetch.maxChars": "Web Fetch Max Chars", "tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 164eacc6ae0..6366d6581f1 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -430,8 +430,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "perplexity", or "grok"). */ - provider?: "brave" | "perplexity" | "grok"; + /** Search provider ("brave", "perplexity", "grok", or "gemini"). */ + provider?: "brave" | "perplexity" | "grok" | "gemini"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; /** Default search results count (1-10). */ @@ -458,6 +458,13 @@ export type ToolsConfig = { /** Include inline citations in response text as markdown links (default: false). */ inlineCitations?: boolean; }; + /** Gemini-specific configuration (used when provider="gemini"). */ + gemini?: { + /** Gemini API key (defaults to GEMINI_API_KEY env var). */ + apiKey?: string; + /** Model to use for grounded search (defaults to "gemini-2.5-flash"). */ + model?: string; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 43a2e0ef96d..e88c22614f0 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -239,7 +239,9 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).optional(), + provider: z + .union([z.literal("brave"), z.literal("perplexity"), z.literal("grok"), z.literal("gemini")]) + .optional(), apiKey: z.string().optional().register(sensitive), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), @@ -260,6 +262,13 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + gemini: z + .object({ + apiKey: z.string().optional().register(sensitive), + model: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .optional(); From 8e821a061cb61c8cd3e3f2a90880ff73099126c1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 09:43:47 -0500 Subject: [PATCH 020/314] fix(telegram): scope polling offsets per bot and await shared runner stop (#24549) * Telegram: scope polling offsets and await shared runner stop * Changelog: remove unrelated session-fix entries from PR * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/telegram/monitor.ts | 19 ++++++---- src/telegram/update-offset-store.test.ts | 44 +++++++++++++++++++++++ src/telegram/update-offset-store.ts | 46 +++++++++++++++++++++--- 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd222ca52e..65c6d2990ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. - Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. +- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index a9eb3fbd8ec..8637f488dd6 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -135,6 +135,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { let lastUpdateId = await readTelegramUpdateOffset({ accountId: account.accountId, + botToken: token, }); const persistUpdateId = async (updateId: number) => { if (lastUpdateId !== null && updateId <= lastUpdateId) { @@ -145,6 +146,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { await writeTelegramUpdateOffset({ accountId: account.accountId, updateId, + botToken: token, }); } catch (err) { (opts.runtime?.error ?? console.error)( @@ -257,9 +259,18 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const runner = run(bot, runnerOptions); activeRunner = runner; + let stopPromise: Promise | undefined; + const stopRunner = () => { + stopPromise ??= Promise.resolve(runner.stop()) + .then(() => undefined) + .catch(() => { + // Runner may already be stopped by abort/retry paths. + }); + return stopPromise; + }; const stopOnAbort = () => { if (opts.abortSignal?.aborted) { - void runner.stop(); + void stopRunner(); } }; opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); @@ -304,11 +315,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } } finally { opts.abortSignal?.removeEventListener("abort", stopOnAbort); - try { - await runner.stop(); - } catch { - // Runner may already be stopped by abort/retry paths. - } + await stopRunner(); } } } finally { diff --git a/src/telegram/update-offset-store.test.ts b/src/telegram/update-offset-store.test.ts index 523038b30f8..96b0ec039c2 100644 --- a/src/telegram/update-offset-store.test.ts +++ b/src/telegram/update-offset-store.test.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; import { @@ -34,4 +36,46 @@ describe("deleteTelegramUpdateOffset", () => { expect(await readTelegramUpdateOffset({ accountId: "alerts" })).toBe(200); }); }); + + it("returns null when stored offset was written by a different bot token", async () => { + await withStateDirEnv("openclaw-tg-offset-", async () => { + await writeTelegramUpdateOffset({ + accountId: "default", + updateId: 321, + botToken: "111111:token-a", + }); + + expect( + await readTelegramUpdateOffset({ + accountId: "default", + botToken: "222222:token-b", + }), + ).toBeNull(); + expect( + await readTelegramUpdateOffset({ + accountId: "default", + botToken: "111111:token-a", + }), + ).toBe(321); + }); + }); + + it("treats legacy offset records without bot identity as stale when token is provided", async () => { + await withStateDirEnv("openclaw-tg-offset-", async ({ stateDir }) => { + const legacyPath = path.join(stateDir, "telegram", "update-offset-default.json"); + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile( + legacyPath, + `${JSON.stringify({ version: 1, lastUpdateId: 777 }, null, 2)}\n`, + "utf-8", + ); + + expect( + await readTelegramUpdateOffset({ + accountId: "default", + botToken: "333333:token-c", + }), + ).toBeNull(); + }); + }); }); diff --git a/src/telegram/update-offset-store.ts b/src/telegram/update-offset-store.ts index 6000c4d1443..dddbc772c9d 100644 --- a/src/telegram/update-offset-store.ts +++ b/src/telegram/update-offset-store.ts @@ -4,11 +4,12 @@ import os from "node:os"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; -const STORE_VERSION = 1; +const STORE_VERSION = 2; type TelegramUpdateOffsetState = { version: number; lastUpdateId: number | null; + botId: string | null; }; function normalizeAccountId(accountId?: string) { @@ -28,16 +29,43 @@ function resolveTelegramUpdateOffsetPath( return path.join(stateDir, "telegram", `update-offset-${normalized}.json`); } +function extractBotIdFromToken(token?: string): string | null { + const trimmed = token?.trim(); + if (!trimmed) { + return null; + } + const [rawBotId] = trimmed.split(":", 1); + if (!rawBotId || !/^\d+$/.test(rawBotId)) { + return null; + } + return rawBotId; +} + function safeParseState(raw: string): TelegramUpdateOffsetState | null { try { - const parsed = JSON.parse(raw) as TelegramUpdateOffsetState; - if (parsed?.version !== STORE_VERSION) { + const parsed = JSON.parse(raw) as { + version?: number; + lastUpdateId?: number | null; + botId?: string | null; + }; + if (parsed?.version !== STORE_VERSION && parsed?.version !== 1) { return null; } if (parsed.lastUpdateId !== null && typeof parsed.lastUpdateId !== "number") { return null; } - return parsed; + if ( + parsed.version === STORE_VERSION && + parsed.botId !== null && + typeof parsed.botId !== "string" + ) { + return null; + } + return { + version: STORE_VERSION, + lastUpdateId: parsed.lastUpdateId ?? null, + botId: parsed.version === STORE_VERSION ? (parsed.botId ?? null) : null, + }; } catch { return null; } @@ -45,12 +73,20 @@ function safeParseState(raw: string): TelegramUpdateOffsetState | null { export async function readTelegramUpdateOffset(params: { accountId?: string; + botToken?: string; env?: NodeJS.ProcessEnv; }): Promise { const filePath = resolveTelegramUpdateOffsetPath(params.accountId, params.env); try { const raw = await fs.readFile(filePath, "utf-8"); const parsed = safeParseState(raw); + const expectedBotId = extractBotIdFromToken(params.botToken); + if (expectedBotId && parsed?.botId && parsed.botId !== expectedBotId) { + return null; + } + if (expectedBotId && parsed?.botId === null) { + return null; + } return parsed?.lastUpdateId ?? null; } catch (err) { const code = (err as { code?: string }).code; @@ -64,6 +100,7 @@ export async function readTelegramUpdateOffset(params: { export async function writeTelegramUpdateOffset(params: { accountId?: string; updateId: number; + botToken?: string; env?: NodeJS.ProcessEnv; }): Promise { const filePath = resolveTelegramUpdateOffsetPath(params.accountId, params.env); @@ -73,6 +110,7 @@ export async function writeTelegramUpdateOffset(params: { const payload: TelegramUpdateOffsetState = { version: STORE_VERSION, lastUpdateId: params.updateId, + botId: extractBotIdFromToken(params.botToken), }; await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf-8", From 7fb69b7cd26a0981931544a556fb67bed8a31e6c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 09:58:47 -0500 Subject: [PATCH 021/314] Gateway: stop repeated unauthorized WS request floods per connection (#24294) * Gateway WS: add unauthorized flood guard primitive * Gateway WS: close repeated unauthorized post-handshake request floods * Gateway WS: test unauthorized flood guard behavior * Changelog: note gateway WS unauthorized flood guard hardening * Update CHANGELOG.md --- CHANGELOG.md | 1 + .../server/ws-connection/message-handler.ts | 31 ++++++++- .../unauthorized-flood-guard.test.ts | 67 ++++++++++++++++++ .../ws-connection/unauthorized-flood-guard.ts | 69 +++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts create mode 100644 src/gateway/server/ws-connection/unauthorized-flood-guard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c6d2990ff..64e8ad6bb9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. - Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. - Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e8f8659a9a7..eb62269c85e 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -78,6 +78,7 @@ import { resolveControlUiAuthPolicy, shouldSkipControlUiPairing, } from "./connect-policy.js"; +import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js"; type SubsystemLogger = ReturnType; @@ -190,6 +191,7 @@ export function attachGatewayWsMessageHandler(params: { } const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); + const unauthorizedFloodGuard = new UnauthorizedFloodGuard(); socket.on("message", async (data) => { if (isClosed()) { @@ -908,6 +910,33 @@ export function attachGatewayWsMessageHandler(params: { meta?: Record, ) => { send({ type: "res", id: req.id, ok, payload, error }); + const unauthorizedRoleError = isUnauthorizedRoleError(error); + let logMeta = meta; + if (unauthorizedRoleError) { + const unauthorizedDecision = unauthorizedFloodGuard.registerUnauthorized(); + if (unauthorizedDecision.suppressedSinceLastLog > 0) { + logMeta = { + ...logMeta, + suppressedUnauthorizedResponses: unauthorizedDecision.suppressedSinceLastLog, + }; + } + if (!unauthorizedDecision.shouldLog) { + return; + } + if (unauthorizedDecision.shouldClose) { + setCloseCause("repeated-unauthorized-requests", { + unauthorizedCount: unauthorizedDecision.count, + method: req.method, + }); + queueMicrotask(() => close(1008, "repeated unauthorized calls")); + } + logMeta = { + ...logMeta, + unauthorizedCount: unauthorizedDecision.count, + }; + } else { + unauthorizedFloodGuard.reset(); + } logWs("out", "res", { connId, id: req.id, @@ -915,7 +944,7 @@ export function attachGatewayWsMessageHandler(params: { method: req.method, errorCode: error?.code, errorMessage: error?.message, - ...meta, + ...logMeta, }); }; diff --git a/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts b/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts new file mode 100644 index 00000000000..8c750570dcf --- /dev/null +++ b/src/gateway/server/ws-connection/unauthorized-flood-guard.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { ErrorCodes, errorShape } from "../../protocol/index.js"; +import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js"; + +describe("UnauthorizedFloodGuard", () => { + it("suppresses repeated unauthorized responses and closes after threshold", () => { + const guard = new UnauthorizedFloodGuard({ closeAfter: 2, logEvery: 3 }); + + const first = guard.registerUnauthorized(); + expect(first).toEqual({ + shouldClose: false, + shouldLog: true, + count: 1, + suppressedSinceLastLog: 0, + }); + + const second = guard.registerUnauthorized(); + expect(second).toEqual({ + shouldClose: false, + shouldLog: false, + count: 2, + suppressedSinceLastLog: 0, + }); + + const third = guard.registerUnauthorized(); + expect(third).toEqual({ + shouldClose: true, + shouldLog: true, + count: 3, + suppressedSinceLastLog: 1, + }); + }); + + it("resets counters", () => { + const guard = new UnauthorizedFloodGuard({ closeAfter: 10, logEvery: 50 }); + guard.registerUnauthorized(); + guard.registerUnauthorized(); + guard.reset(); + + const next = guard.registerUnauthorized(); + expect(next).toEqual({ + shouldClose: false, + shouldLog: true, + count: 1, + suppressedSinceLastLog: 0, + }); + }); +}); + +describe("isUnauthorizedRoleError", () => { + it("detects unauthorized role responses", () => { + expect( + isUnauthorizedRoleError(errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized role: node")), + ).toBe(true); + }); + + it("ignores non-role authorization errors", () => { + expect( + isUnauthorizedRoleError( + errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"), + ), + ).toBe(false); + expect(isUnauthorizedRoleError(errorShape(ErrorCodes.UNAVAILABLE, "service unavailable"))).toBe( + false, + ); + }); +}); diff --git a/src/gateway/server/ws-connection/unauthorized-flood-guard.ts b/src/gateway/server/ws-connection/unauthorized-flood-guard.ts new file mode 100644 index 00000000000..f7a7636b594 --- /dev/null +++ b/src/gateway/server/ws-connection/unauthorized-flood-guard.ts @@ -0,0 +1,69 @@ +import { ErrorCodes, type ErrorShape } from "../../protocol/index.js"; + +export type UnauthorizedFloodGuardOptions = { + closeAfter?: number; + logEvery?: number; +}; + +export type UnauthorizedFloodDecision = { + shouldClose: boolean; + shouldLog: boolean; + count: number; + suppressedSinceLastLog: number; +}; + +const DEFAULT_CLOSE_AFTER = 10; +const DEFAULT_LOG_EVERY = 100; + +export class UnauthorizedFloodGuard { + private readonly closeAfter: number; + private readonly logEvery: number; + private count = 0; + private suppressedSinceLastLog = 0; + + constructor(options?: UnauthorizedFloodGuardOptions) { + this.closeAfter = Math.max(1, Math.floor(options?.closeAfter ?? DEFAULT_CLOSE_AFTER)); + this.logEvery = Math.max(1, Math.floor(options?.logEvery ?? DEFAULT_LOG_EVERY)); + } + + registerUnauthorized(): UnauthorizedFloodDecision { + this.count += 1; + const shouldClose = this.count > this.closeAfter; + const shouldLog = this.count === 1 || this.count % this.logEvery === 0 || shouldClose; + + if (!shouldLog) { + this.suppressedSinceLastLog += 1; + return { + shouldClose, + shouldLog: false, + count: this.count, + suppressedSinceLastLog: 0, + }; + } + + const suppressedSinceLastLog = this.suppressedSinceLastLog; + this.suppressedSinceLastLog = 0; + return { + shouldClose, + shouldLog: true, + count: this.count, + suppressedSinceLastLog, + }; + } + + reset(): void { + this.count = 0; + this.suppressedSinceLastLog = 0; + } +} + +export function isUnauthorizedRoleError(error?: ErrorShape): boolean { + if (!error) { + return false; + } + return ( + error.code === ErrorCodes.INVALID_REQUEST && + typeof error.message === "string" && + error.message.startsWith("unauthorized role:") + ); +} From 69692d0d3a34bb3bd0e2ecf9594a239809cfe7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Mon, 23 Feb 2026 23:03:56 +0800 Subject: [PATCH 022/314] fix: detect additional context overflow error patterns to prevent leak to user (#20539) * fix: detect additional context overflow error patterns to prevent leak to user Fixes #9951 The error 'input length and max_tokens exceed context limit: 170636 + 34048 > 200000' was not caught by isContextOverflowError() and leaked to users via formatAssistantErrorText()'s invalidRequest fallback. Add three new patterns to isContextOverflowError(): - 'exceed context limit' (direct match) - 'exceeds the model\'s maximum context' - max_tokens/input length + exceed + context (compound match) These are now rewritten to the friendly context overflow message. * Overflow: add regression tests and changelog credits * Update CHANGELOG.md * Update pi-embedded-helpers.isbillingerrormessage.test.ts --------- Co-authored-by: echoVic Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + ...pi-embedded-helpers.isbillingerrormessage.test.ts | 12 ++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e8ad6bb9d..0755d0af519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. - Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. ## 2026.2.23 diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index ba06360ac56..5900ec5dfc6 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -189,6 +189,18 @@ describe("isContextOverflowError", () => { } }); + it("matches exceed/context/max_tokens overflow variants", () => { + const samples = [ + "input length and max_tokens exceed context limit (i.e 156321 + 48384 > 200000)", + "This request exceeds the model's maximum context length", + "LLM request rejected: max_tokens would exceed context window", + "input length would exceed context budget for this model", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + it("ignores normal conversation text mentioning context overflow", () => { // These are legitimate conversation snippets, not error messages expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6d6c355d0a7..86ded785629 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -54,6 +54,10 @@ export function isContextOverflowError(errorMessage?: string): boolean { lower.includes("model token limit") || (hasRequestSizeExceeds && hasContextWindow) || lower.includes("context overflow:") || + lower.includes("exceed context limit") || + lower.includes("exceeds the model's maximum context") || + (lower.includes("max_tokens") && lower.includes("exceed") && lower.includes("context")) || + (lower.includes("input length") && lower.includes("exceed") && lower.includes("context")) || (lower.includes("413") && lower.includes("too large")) ); } From 01380f49f5a287f1195e4d7b21ae2fc74ef8d997 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 24 Feb 2026 02:14:21 +1100 Subject: [PATCH 023/314] fix(compaction): pass model through runtime for safeguard summaries (#17864) * fix(compaction): pass model through runtime to fix ctx.model undefined Fixes #3479 Root cause: extensionRunner.initialize() is never called in compact.ts workflow, leaving ctx.model undefined. Compaction safeguard checks ctx.model and returns fallback summary immediately without attempting LLM summarization. Changes: 1. Pass model through compaction safeguard runtime registry (same pattern as maxHistoryShare) 2. Fall back to runtime.model when ctx.model is undefined 3. Add once-per-session warning when both models are missing (prevents log spam) 4. Add regression test for runtime.model fallback This follows the established runtime registry pattern rather than attempting to call extensionRunner.initialize() (which is SDK-internal and not meant for direct access). Co-Authored-By: Claude Sonnet 4.5 * test: add comprehensive tests for compaction-safeguard model fallback Add integration tests to verify the model fallback behavior: - Test runtime.model fallback when ctx.model is undefined (compact.ts workflow) - Test fallback summary when both ctx.model and runtime.model are undefined - Test contextWindowTokens runtime storage/retrieval - Test combined runtime values (maxHistoryShare + contextWindowTokens + model) These tests verify the fix for issue #3479 where compaction fails due to ctx.model being undefined in the compact.ts workflow. The runtime registry pattern allows model to be passed when extensionRunner.initialize() is not called, ensuring summarization works in all code paths. Related: PR #17864 * fix(test): adapt compaction-safeguard tests to upstream type changes - Add baseUrl to Model mock objects (now required by Model) - Add explicit Model annotation to prevent provider string widening - Cast modelRegistry mock through unknown (ModelRegistry expanded) - Use non-null assertion for compactionHandler (TypeScript strict) - Type compaction result explicitly Co-Authored-By: Claude Opus 4.6 * Compaction: add changelog credit for model fallback fix * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/extensions.ts | 1 + .../compaction-safeguard-runtime.ts | 7 + .../compaction-safeguard.test.ts | 233 +++++++++++++++++- .../pi-extensions/compaction-safeguard.ts | 19 +- 5 files changed, 257 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0755d0af519..8598d8a90d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. - Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index cdaa47b0959..fc0e76acdc9 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -81,6 +81,7 @@ export function buildEmbeddedExtensionFactories(params: { setCompactionSafeguardRuntime(params.sessionManager, { maxHistoryShare: compactionCfg?.maxHistoryShare, contextWindowTokens: contextWindowInfo.tokens, + model: params.model, }); factories.push(compactionSafeguardExtension); } diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts index df3919cf815..7391e3c1cba 100644 --- a/src/agents/pi-extensions/compaction-safeguard-runtime.ts +++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts @@ -1,8 +1,15 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js"; export type CompactionSafeguardRuntimeValue = { maxHistoryShare?: number; contextWindowTokens?: number; + /** + * Model to use for compaction summarization. + * Passed through runtime because `ctx.model` is undefined in the compact.ts workflow + * (extensionRunner.initialize() is never called in that path). + */ + model?: Model; }; const registry = createSessionManagerRuntimeRegistry(); diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 3d5fab422a4..b5e4915d023 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -1,10 +1,12 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it } from "vitest"; +import type { Api, Model } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; import { getCompactionSafeguardRuntime, setCompactionSafeguardRuntime, } from "./compaction-safeguard-runtime.js"; -import { __testing } from "./compaction-safeguard.js"; +import compactionSafeguardExtension, { __testing } from "./compaction-safeguard.js"; const { collectToolFailures, @@ -16,6 +18,25 @@ const { SAFETY_MARGIN, } = __testing; +function stubSessionManager(): ExtensionContext["sessionManager"] { + const stub: ExtensionContext["sessionManager"] = { + getCwd: () => "/stub", + getSessionDir: () => "/stub", + getSessionId: () => "stub-id", + getSessionFile: () => undefined, + getLeafId: () => null, + getLeafEntry: () => undefined, + getEntry: () => undefined, + getLabel: () => undefined, + getBranch: () => [], + getHeader: () => null, + getEntries: () => [], + getTree: () => [], + getSessionName: () => undefined, + }; + return stub; +} + describe("compaction-safeguard tool failures", () => { it("formats tool failures with meta and summary", () => { const messages: AgentMessage[] = [ @@ -248,4 +269,212 @@ describe("compaction-safeguard runtime registry", () => { expect(getCompactionSafeguardRuntime(sm1)).toEqual({ maxHistoryShare: 0.3 }); expect(getCompactionSafeguardRuntime(sm2)).toEqual({ maxHistoryShare: 0.8 }); }); + + it("stores and retrieves model from runtime (fallback for compact.ts workflow)", () => { + const sm = {}; + const model: Model = { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "anthropic", + api: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + contextWindow: 200000, + maxTokens: 4096, + reasoning: false, + input: ["text"] as const, + cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 }, + }; + setCompactionSafeguardRuntime(sm, { model }); + const retrieved = getCompactionSafeguardRuntime(sm); + expect(retrieved?.model).toEqual(model); + }); + + it("stores and retrieves contextWindowTokens from runtime", () => { + const sm = {}; + setCompactionSafeguardRuntime(sm, { contextWindowTokens: 200000 }); + const retrieved = getCompactionSafeguardRuntime(sm); + expect(retrieved?.contextWindowTokens).toBe(200000); + }); + + it("stores and retrieves combined runtime values", () => { + const sm = {}; + const model: Model = { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "anthropic", + api: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + contextWindow: 200000, + maxTokens: 4096, + reasoning: false, + input: ["text"] as const, + cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 }, + }; + setCompactionSafeguardRuntime(sm, { + maxHistoryShare: 0.6, + contextWindowTokens: 200000, + model, + }); + const retrieved = getCompactionSafeguardRuntime(sm); + expect(retrieved).toEqual({ + maxHistoryShare: 0.6, + contextWindowTokens: 200000, + model, + }); + }); +}); + +describe("compaction-safeguard extension model fallback", () => { + it("uses runtime.model when ctx.model is undefined (compact.ts workflow)", async () => { + // This test verifies the root-cause fix: when extensionRunner.initialize() is not called + // (as happens in compact.ts), ctx.model is undefined but runtime.model is available. + const sessionManager = stubSessionManager(); + const model: Model = { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + provider: "anthropic", + api: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + contextWindow: 200000, + maxTokens: 4096, + reasoning: false, + input: ["text"] as const, + cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 }, + }; + + // Set up runtime with model (mimics buildEmbeddedExtensionPaths behavior) + setCompactionSafeguardRuntime(sessionManager, { model }); + + type CompactionHandler = (event: unknown, ctx: unknown) => Promise; + let compactionHandler: CompactionHandler | undefined; + + // Create a minimal mock ExtensionAPI that captures the handler + const mockApi = { + on: vi.fn((event: string, handler: CompactionHandler) => { + if (event === "session_before_compact") { + compactionHandler = handler; + } + }), + } as unknown as ExtensionAPI; + + // Register the extension + compactionSafeguardExtension(mockApi); + + // Verify handler was registered + expect(compactionHandler).toBeDefined(); + + // Now trigger the handler with mock data + const mockEvent = { + preparation: { + messagesToSummarize: [ + { role: "user", content: "test message", timestamp: Date.now() }, + ] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-1", + tokensBefore: 1000, + fileOps: { + read: [], + edited: [], + written: [], + }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + + const getApiKeyMock = vi.fn().mockResolvedValue(null); + // oxlint-disable-next-line typescript/no-explicit-any + const mockContext = { + model: undefined, // ctx.model is undefined (simulates compact.ts workflow) + sessionManager, + modelRegistry: { + getApiKey: getApiKeyMock, // No API key, should use fallback + }, + } as unknown as Partial; + + // Call the handler and wait for result + // oxlint-disable-next-line typescript/no-non-null-assertion + const result = (await compactionHandler!(mockEvent, mockContext)) as { + compaction?: { summary?: string; firstKeptEntryId?: string }; + }; + const compactionResult = result?.compaction; + + // Verify that compaction returned a result (even with null API key, should use fallback) + expect(compactionResult).toBeDefined(); + expect(compactionResult?.summary).toBeDefined(); + expect(compactionResult?.summary).toContain("Summary unavailable"); + expect(compactionResult?.firstKeptEntryId).toBe("entry-1"); + + // KEY ASSERTION: Prove the fallback path was exercised + // The handler should have called getApiKey with runtime.model (via ctx.model ?? runtime?.model) + expect(getApiKeyMock).toHaveBeenCalledWith(model); + + // Verify runtime.model is still available (for completeness) + const retrieved = getCompactionSafeguardRuntime(sessionManager); + expect(retrieved?.model).toEqual(model); + }); + + it("returns fallback summary when both ctx.model and runtime.model are undefined", async () => { + const sessionManager = stubSessionManager(); + + // Do NOT set runtime.model (both ctx.model and runtime.model will be undefined) + + type CompactionHandler = (event: unknown, ctx: unknown) => Promise; + let compactionHandler: CompactionHandler | undefined; + + const mockApi = { + on: vi.fn((event: string, handler: CompactionHandler) => { + if (event === "session_before_compact") { + compactionHandler = handler; + } + }), + } as unknown as ExtensionAPI; + + compactionSafeguardExtension(mockApi); + + expect(compactionHandler).toBeDefined(); + + const mockEvent = { + preparation: { + messagesToSummarize: [ + { role: "user", content: "test", timestamp: Date.now() }, + ] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-1", + tokensBefore: 500, + fileOps: { + read: [], + edited: [], + written: [], + }, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + + const getApiKeyMock = vi.fn().mockResolvedValue(null); + // oxlint-disable-next-line typescript/no-explicit-any + const mockContext = { + model: undefined, // ctx.model is undefined + sessionManager, + modelRegistry: { + getApiKey: getApiKeyMock, // Should NOT be called (early return) + }, + } as unknown as Partial; + + // oxlint-disable-next-line typescript/no-non-null-assertion + const result = (await compactionHandler!(mockEvent, mockContext)) as { + compaction?: { summary?: string; firstKeptEntryId?: string }; + }; + const compactionResult = result?.compaction; + + expect(compactionResult).toBeDefined(); + expect(compactionResult?.summary).toBe( + "Summary unavailable due to context limits. Older messages were truncated.", + ); + expect(compactionResult?.firstKeptEntryId).toBe("entry-1"); + + // Verify early return: getApiKey should NOT have been called when both models are missing + expect(getApiKeyMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 6406c3d8a30..a728a4a724e 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -22,6 +22,9 @@ import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js const log = createSubsystemLogger("compaction-safeguard"); const FALLBACK_SUMMARY = "Summary unavailable due to context limits. Older messages were truncated."; + +// Track session managers that have already logged the missing-model warning to avoid log spam. +const missedModelWarningSessions = new WeakSet(); const TURN_PREFIX_INSTRUCTIONS = "This summary covers the prefix of a split turn. Focus on the original request," + " early progress, and any details needed to understand the retained suffix."; @@ -199,8 +202,21 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { const toolFailureSection = formatToolFailuresSection(toolFailures); const fallbackSummary = `${FALLBACK_SUMMARY}${toolFailureSection}${fileOpsSummary}`; - const model = ctx.model; + // 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 model = ctx.model ?? runtime?.model; if (!model) { + // Log warning once per session when both models are missing (diagnostic for future issues). + // Use a WeakSet to track which session managers have already logged the warning. + if (!ctx.model && !runtime?.model && !missedModelWarningSessions.has(ctx.sessionManager)) { + missedModelWarningSessions.add(ctx.sessionManager); + console.warn( + "[compaction-safeguard] Both ctx.model and runtime.model are undefined. " + + "Compaction summarization will not run. This indicates extensionRunner.initialize() " + + "was not called and model was not passed through runtime registry.", + ); + } return { compaction: { summary: fallbackSummary, @@ -224,7 +240,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { } try { - const runtime = getCompactionSafeguardRuntime(ctx.sessionManager); const modelContextWindow = resolveContextWindowTokens(model); const contextWindowTokens = runtime?.contextWindowTokens ?? modelContextWindow; const turnPrefixMessages = preparation.turnPrefixMessages ?? []; From ea47ab29bd6d92394185636a27c3572c19aac8e5 Mon Sep 17 00:00:00 2001 From: DukeDeSouth <51200688+DukeDeSouth@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:23:13 -0500 Subject: [PATCH 024/314] fix: cancel compaction instead of truncating history when summarization fails (#10711) * fix: cancel compaction instead of truncating history when summarization fails When the compaction safeguard cannot generate a summary (no model, no API key, or LLM error), it previously returned a "Summary unavailable" fallback string and still truncated history. This caused irreversible data loss - older messages were discarded even though no meaningful summary was produced. Now returns `{ cancel: true }` in all three failure paths so the framework aborts compaction entirely and preserves the full conversation history. Fixes #10332 Co-authored-by: Cursor * fix: use deterministic timestamps in compaction safeguard tests Replace Date.now() with fixed timestamp (0) in test data to prevent nondeterministic behavior in snapshot-based or order-dependent tests. Co-authored-by: Cursor * Changelog: note compaction cancellation safeguard fix --------- Co-authored-by: Cursor Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../compaction-safeguard.test.ts | 22 ++++-------- .../pi-extensions/compaction-safeguard.ts | 35 ++++--------------- 3 files changed, 14 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8598d8a90d9..24316eb860d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. - Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. +- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. - Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. ## 2026.2.23 diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index b5e4915d023..e0033b0bb75 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -388,22 +388,17 @@ describe("compaction-safeguard extension model fallback", () => { model: undefined, // ctx.model is undefined (simulates compact.ts workflow) sessionManager, modelRegistry: { - getApiKey: getApiKeyMock, // No API key, should use fallback + getApiKey: getApiKeyMock, // No API key should now cancel compaction }, } as unknown as Partial; // Call the handler and wait for result // oxlint-disable-next-line typescript/no-non-null-assertion const result = (await compactionHandler!(mockEvent, mockContext)) as { - compaction?: { summary?: string; firstKeptEntryId?: string }; + cancel?: boolean; }; - const compactionResult = result?.compaction; - // Verify that compaction returned a result (even with null API key, should use fallback) - expect(compactionResult).toBeDefined(); - expect(compactionResult?.summary).toBeDefined(); - expect(compactionResult?.summary).toContain("Summary unavailable"); - expect(compactionResult?.firstKeptEntryId).toBe("entry-1"); + expect(result).toEqual({ cancel: true }); // KEY ASSERTION: Prove the fallback path was exercised // The handler should have called getApiKey with runtime.model (via ctx.model ?? runtime?.model) @@ -414,7 +409,7 @@ describe("compaction-safeguard extension model fallback", () => { expect(retrieved?.model).toEqual(model); }); - it("returns fallback summary when both ctx.model and runtime.model are undefined", async () => { + it("cancels compaction when both ctx.model and runtime.model are undefined", async () => { const sessionManager = stubSessionManager(); // Do NOT set runtime.model (both ctx.model and runtime.model will be undefined) @@ -464,15 +459,10 @@ describe("compaction-safeguard extension model fallback", () => { // oxlint-disable-next-line typescript/no-non-null-assertion const result = (await compactionHandler!(mockEvent, mockContext)) as { - compaction?: { summary?: string; firstKeptEntryId?: string }; + cancel?: boolean; }; - const compactionResult = result?.compaction; - expect(compactionResult).toBeDefined(); - expect(compactionResult?.summary).toBe( - "Summary unavailable due to context limits. Older messages were truncated.", - ); - expect(compactionResult?.firstKeptEntryId).toBe("entry-1"); + expect(result).toEqual({ cancel: true }); // Verify early return: getApiKey should NOT have been called when both models are missing expect(getApiKeyMock).not.toHaveBeenCalled(); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a728a4a724e..b7c15d50397 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -20,8 +20,6 @@ import { collectTextContentBlocks } from "../content-blocks.js"; import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js"; const log = createSubsystemLogger("compaction-safeguard"); -const FALLBACK_SUMMARY = - "Summary unavailable due to context limits. Older messages were truncated."; // Track session managers that have already logged the missing-model warning to avoid log spam. const missedModelWarningSessions = new WeakSet(); @@ -200,7 +198,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { ...preparation.turnPrefixMessages, ]); const toolFailureSection = formatToolFailuresSection(toolFailures); - const fallbackSummary = `${FALLBACK_SUMMARY}${toolFailureSection}${fileOpsSummary}`; // 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. @@ -217,26 +214,15 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { "was not called and model was not passed through runtime registry.", ); } - return { - compaction: { - summary: fallbackSummary, - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - details: { readFiles, modifiedFiles }, - }, - }; + return { cancel: true }; } const apiKey = await ctx.modelRegistry.getApiKey(model); if (!apiKey) { - return { - compaction: { - summary: fallbackSummary, - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - details: { readFiles, modifiedFiles }, - }, - }; + console.warn( + "Compaction safeguard: no API key available; cancelling compaction to preserve history.", + ); + return { cancel: true }; } try { @@ -375,18 +361,11 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }; } catch (error) { log.warn( - `Compaction summarization failed; truncating history: ${ + `Compaction summarization failed; cancelling compaction to preserve history: ${ error instanceof Error ? error.message : String(error) }`, ); - return { - compaction: { - summary: fallbackSummary, - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - details: { readFiles, modifiedFiles }, - }, - }; + return { cancel: true }; } }); } From c1b75ab8e2636667d50b03f15295ce43cef54341 Mon Sep 17 00:00:00 2001 From: LI SHANXIN <128674037+PeterShanxin@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:25:14 +0800 Subject: [PATCH 025/314] fix(telegram): make reaction handling soft-fail and message-id resilient (#20236) * Telegram: soft-fail reactions and fallback to inbound message id * Telegram: soft-fail missing reaction message id * Update CHANGELOG.md --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/agents/openclaw-tools.ts | 3 + src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 2 + src/agents/pi-tools.ts | 3 + src/agents/tools/common.params.test.ts | 10 ++ src/agents/tools/common.ts | 26 +++- src/agents/tools/message-tool.ts | 21 +++- src/agents/tools/telegram-actions.test.ts | 118 +++++++++++++------ src/agents/tools/telegram-actions.ts | 59 +++++++--- src/auto-reply/reply/agent-runner-utils.ts | 12 +- src/channels/dock.test.ts | 25 +++- src/channels/dock.ts | 18 ++- src/channels/plugins/actions/actions.test.ts | 77 ++++++++++++ src/channels/plugins/actions/telegram.ts | 7 +- src/channels/plugins/types.core.ts | 2 + 17 files changed, 317 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24316eb860d..960ae605204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. - Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. - Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 41f059fb6a7..0cc577bb42b 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -49,6 +49,8 @@ export function createOpenClawTools(options?: { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Current inbound message id for action fallbacks (e.g. Telegram react). */ + currentMessageId?: string | number; /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ @@ -96,6 +98,7 @@ export function createOpenClawTools(options?: { currentChannelId: options?.currentChannelId, currentChannelProvider: options?.agentChannel, currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, sandboxRoot: options?.sandboxRoot, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index aa603b171ed..f92b6a375a7 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -582,6 +582,7 @@ export async function runEmbeddedPiAgent( senderIsOwner: params.senderIsOwner, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, replyToMode: params.replyToMode, hasRepliedRef: params.hasRepliedRef, sessionFile: params.sessionFile, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ab9c557f84a..9c636afa4cd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -391,6 +391,7 @@ export async function runEmbeddedAttempt( modelAuthMode: resolveModelAuthMode(params.model.provider, params.config), currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, replyToMode: params.replyToMode, hasRepliedRef: params.hasRepliedRef, modelHasVision, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index b5edec514a4..da0e9eae050 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -48,6 +48,8 @@ export type RunEmbeddedPiAgentParams = { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Current inbound message id for action fallbacks (e.g. Telegram react). */ + currentMessageId?: string | number; /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index f40226c960c..4b09d9ad993 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -199,6 +199,8 @@ export function createOpenClawCodingTools(options?: { currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; + /** Current inbound message id for action fallbacks (e.g. Telegram react). */ + currentMessageId?: string | number; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ @@ -472,6 +474,7 @@ export function createOpenClawCodingTools(options?: { ]), currentChannelId: options?.currentChannelId, currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, modelHasVision: options?.modelHasVision, diff --git a/src/agents/tools/common.params.test.ts b/src/agents/tools/common.params.test.ts index ba6044ea72b..d93038cd606 100644 --- a/src/agents/tools/common.params.test.ts +++ b/src/agents/tools/common.params.test.ts @@ -35,6 +35,11 @@ describe("readStringOrNumberParam", () => { const params = { chatId: " abc " }; expect(readStringOrNumberParam(params, "chatId")).toBe("abc"); }); + + it("accepts snake_case aliases for camelCase keys", () => { + const params = { chat_id: "123" }; + expect(readStringOrNumberParam(params, "chatId")).toBe("123"); + }); }); describe("readNumberParam", () => { @@ -47,6 +52,11 @@ describe("readNumberParam", () => { const params = { messageId: "42.9" }; expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); }); + + it("accepts snake_case aliases for camelCase keys", () => { + const params = { message_id: "42" }; + expect(readNumberParam(params, "messageId")).toBe(42); + }); }); describe("required parameter validation", () => { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 1aea6dd3cfa..d4b3bc9fc3b 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -53,6 +53,24 @@ export function createActionGate>( }; } +function toSnakeCaseKey(key: string): string { + return key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); +} + +function readParamRaw(params: Record, key: string): unknown { + if (Object.hasOwn(params, key)) { + return params[key]; + } + const snakeKey = toSnakeCaseKey(key); + if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { + return params[snakeKey]; + } + return undefined; +} + export function readStringParam( params: Record, key: string, @@ -69,7 +87,7 @@ export function readStringParam( options: StringParamOptions = {}, ) { const { required = false, trim = true, label = key, allowEmpty = false } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); if (typeof raw !== "string") { if (required) { throw new ToolInputError(`${label} required`); @@ -92,7 +110,7 @@ export function readStringOrNumberParam( options: { required?: boolean; label?: string } = {}, ): string | undefined { const { required = false, label = key } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); if (typeof raw === "number" && Number.isFinite(raw)) { return String(raw); } @@ -114,7 +132,7 @@ export function readNumberParam( options: { required?: boolean; label?: string; integer?: boolean } = {}, ): number | undefined { const { required = false, label = key, integer = false } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); let value: number | undefined; if (typeof raw === "number" && Number.isFinite(raw)) { value = raw; @@ -152,7 +170,7 @@ export function readStringArrayParam( options: StringParamOptions = {}, ) { const { required = false, label = key } = options; - const raw = params[key]; + const raw = readParamRaw(params, key); if (Array.isArray(raw)) { const values = raw .filter((entry) => typeof entry === "string") diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d361cc76f34..31b231cf1ed 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -238,7 +238,19 @@ function buildSendSchema(options: { function buildReactionSchema() { return { - messageId: Type.Optional(Type.String()), + messageId: Type.Optional( + Type.String({ + description: + "Target message id for reaction. For Telegram, if omitted, defaults to the current inbound message id when available.", + }), + ), + message_id: Type.Optional( + Type.String({ + // Intentional duplicate alias for tool-schema discoverability in LLMs. + description: + "snake_case alias of messageId. For Telegram, if omitted, defaults to the current inbound message id when available.", + }), + ), emoji: Type.Optional(Type.String()), remove: Type.Optional(Type.Boolean()), targetAuthor: Type.Optional(Type.String()), @@ -425,6 +437,7 @@ type MessageToolOptions = { currentChannelId?: string; currentChannelProvider?: string; currentThreadTs?: string; + currentMessageId?: string | number; replyToMode?: "off" | "first" | "all"; hasRepliedRef?: { value: boolean }; sandboxRoot?: string; @@ -633,17 +646,23 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND, }; + const hasCurrentMessageId = + typeof options?.currentMessageId === "number" || + (typeof options?.currentMessageId === "string" && + options.currentMessageId.trim().length > 0); const toolContext = options?.currentChannelId || options?.currentChannelProvider || options?.currentThreadTs || + hasCurrentMessageId || options?.replyToMode || options?.hasRepliedRef ? { currentChannelId: options?.currentChannelId, currentChannelProvider: options?.currentChannelProvider, currentThreadTs: options?.currentThreadTs, + currentMessageId: options?.currentMessageId, replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, // Direct tool invocations should not add cross-context decoration. diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 1fdc09f18e5..ea7fcddcbb5 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -102,6 +102,46 @@ describe("handleTelegramAction", () => { await expectReactionAdded("extensive"); }); + it("accepts snake_case message_id for reactions", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, + } as OpenClawConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + message_id: "456", + emoji: "✅", + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith( + "123", + 456, + "✅", + expect.objectContaining({ token: "tok", remove: false }), + ); + }); + + it("soft-fails when messageId is missing", async () => { + const cfg = { + channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, + } as OpenClawConfig; + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + emoji: "✅", + }, + cfg, + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "missing_message_id", + }); + expect(reactMessageTelegram).not.toHaveBeenCalled(); + }); + it("removes reactions on empty emoji", async () => { const cfg = { channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } }, @@ -177,18 +217,10 @@ describe("handleTelegramAction", () => { ); }); - it.each([ - { - level: "off" as const, - expectedMessage: /Telegram agent reactions disabled.*reactionLevel="off"/, - }, - { - level: "ack" as const, - expectedMessage: /Telegram agent reactions disabled.*reactionLevel="ack"/, - }, - ])("blocks reactions when reactionLevel is $level", async ({ level, expectedMessage }) => { - await expect( - handleTelegramAction( + it.each(["off", "ack"] as const)( + "soft-fails reactions when reactionLevel is %s", + async (level) => { + const result = await handleTelegramAction( { action: "react", chatId: "123", @@ -196,11 +228,15 @@ describe("handleTelegramAction", () => { emoji: "✅", }, reactionConfig(level), - ), - ).rejects.toThrow(expectedMessage); - }); + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "disabled", + }); + }, + ); - it("also respects legacy actions.reactions gating", async () => { + it("soft-fails when reactions are disabled via actions.reactions", async () => { const cfg = { channels: { telegram: { @@ -210,17 +246,19 @@ describe("handleTelegramAction", () => { }, }, } as OpenClawConfig; - await expect( - handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: "456", - emoji: "✅", - }, - cfg, - ), - ).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/); + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "disabled", + }); }); it("sends a text message", async () => { @@ -634,18 +672,20 @@ describe("handleTelegramAction per-account gating", () => { }, } as OpenClawConfig; - await expect( - handleTelegramAction( - { - action: "react", - chatId: "123", - messageId: 1, - emoji: "👀", - accountId: "media", - }, - cfg, - ), - ).rejects.toThrow(/reactions are disabled via actions.reactions/i); + const result = await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: 1, + emoji: "👀", + accountId: "media", + }, + cfg, + ); + expect(result.details).toMatchObject({ + ok: false, + reason: "disabled", + }); }); it("allows account to explicitly re-enable top-level disabled reaction gate", async () => { diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 6bcf67784a4..795ac388d05 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -94,42 +94,69 @@ export async function handleTelegramAction( const isActionEnabled = createTelegramActionGate({ cfg, accountId }); if (action === "react") { - // Check reaction level first + // All react failures return soft results (jsonResult with ok:false) instead + // of throwing, because hard tool errors can trigger model re-generation + // loops and duplicate content. const reactionLevelInfo = resolveTelegramReactionLevel({ cfg, accountId: accountId ?? undefined, }); if (!reactionLevelInfo.agentReactionsEnabled) { - throw new Error( - `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` + - `Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`, - ); + return jsonResult({ + ok: false, + reason: "disabled", + hint: `Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). Do not retry.`, + }); } - // Also check the existing action gate for backward compatibility if (!isActionEnabled("reactions")) { - throw new Error("Telegram reactions are disabled via actions.reactions."); + return jsonResult({ + ok: false, + reason: "disabled", + hint: "Telegram reactions are disabled via actions.reactions. Do not retry.", + }); } const chatId = readStringOrNumberParam(params, "chatId", { required: true, }); const messageId = readNumberParam(params, "messageId", { - required: true, integer: true, }); + if (typeof messageId !== "number" || !Number.isFinite(messageId) || messageId <= 0) { + return jsonResult({ + ok: false, + reason: "missing_message_id", + hint: "Telegram reaction requires a valid messageId (or inbound context fallback). Do not retry.", + }); + } const { emoji, remove, isEmpty } = readReactionParams(params, { removeErrorMessage: "Emoji is required to remove a Telegram reaction.", }); const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { - throw new Error( - "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", - ); + return jsonResult({ + ok: false, + reason: "missing_token", + hint: "Telegram bot token missing. Do not retry.", + }); + } + let reactionResult: Awaited>; + try { + reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { + token, + remove, + accountId: accountId ?? undefined, + }); + } catch (err) { + const isInvalid = String(err).includes("REACTION_INVALID"); + return jsonResult({ + ok: false, + reason: isInvalid ? "REACTION_INVALID" : "error", + emoji, + hint: isInvalid + ? "This emoji is not supported for Telegram reactions. Add it to your reaction disallow list so you do not try it again." + : "Reaction failed. Do not retry.", + }); } - const reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { - token, - remove, - accountId: accountId ?? undefined, - }); if (!reactionResult.ok) { return jsonResult({ ok: false, diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 3402e8924c0..58cf1951227 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -22,12 +22,17 @@ export function buildThreadingToolContext(params: { hasRepliedRef: { value: boolean } | undefined; }): ChannelThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; + const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid; if (!config) { - return {}; + return { + currentMessageId, + }; } const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); if (!rawProvider) { - return {}; + return { + currentMessageId, + }; } const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider); // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) @@ -36,6 +41,7 @@ export function buildThreadingToolContext(params: { return { currentChannelId: sessionCtx.To?.trim() || undefined, currentChannelProvider: provider ?? (rawProvider as ChannelId), + currentMessageId, hasRepliedRef, }; } @@ -48,6 +54,7 @@ export function buildThreadingToolContext(params: { From: sessionCtx.From, To: sessionCtx.To, ChatType: sessionCtx.ChatType, + CurrentMessageId: currentMessageId, ReplyToId: sessionCtx.ReplyToId, ThreadLabel: sessionCtx.ThreadLabel, MessageThreadId: sessionCtx.MessageThreadId, @@ -57,6 +64,7 @@ export function buildThreadingToolContext(params: { return { ...context, currentChannelProvider: provider!, // guaranteed non-null since dock exists + currentMessageId: context.currentMessageId ?? currentMessageId, }; } diff --git a/src/channels/dock.test.ts b/src/channels/dock.test.ts index dcd7ecfa7dc..bfb544a3721 100644 --- a/src/channels/dock.test.ts +++ b/src/channels/dock.test.ts @@ -14,7 +14,12 @@ describe("channels dock", () => { const telegramContext = telegramDock?.threading?.buildToolContext?.({ cfg: emptyConfig(), - context: { To: " room-1 ", MessageThreadId: 42, ReplyToId: "fallback" }, + context: { + To: " room-1 ", + MessageThreadId: 42, + ReplyToId: "fallback", + CurrentMessageId: "9001", + }, hasRepliedRef, }); const googleChatContext = googleChatDock?.threading?.buildToolContext?.({ @@ -26,6 +31,7 @@ describe("channels dock", () => { expect(telegramContext).toEqual({ currentChannelId: "room-1", currentThreadTs: "42", + currentMessageId: "9001", hasRepliedRef, }); expect(googleChatContext).toEqual({ @@ -35,6 +41,23 @@ describe("channels dock", () => { }); }); + it("telegram threading does not treat ReplyToId as thread id in DMs", () => { + const hasRepliedRef = { value: false }; + const telegramDock = getChannelDock("telegram"); + const context = telegramDock?.threading?.buildToolContext?.({ + cfg: emptyConfig(), + context: { To: " dm-1 ", ReplyToId: "12345", CurrentMessageId: "12345" }, + hasRepliedRef, + }); + + expect(context).toEqual({ + currentChannelId: "dm-1", + currentThreadTs: undefined, + currentMessageId: "12345", + hasRepliedRef, + }); + }); + it("irc resolveDefaultTo matches account id case-insensitively", () => { const ircDock = getChannelDock("irc"); const cfg = { diff --git a/src/channels/dock.ts b/src/channels/dock.ts index c773aa43cf7..3cec944b800 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -253,8 +253,22 @@ const DOCKS: Record = { }, threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => - buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }), + buildToolContext: ({ context, hasRepliedRef }) => { + // Telegram auto-threading should only use actual thread/topic IDs. + // ReplyToId is a message ID and causes invalid message_thread_id in DMs. + const threadId = context.MessageThreadId; + const rawCurrentMessageId = context.CurrentMessageId; + const currentMessageId = + typeof rawCurrentMessageId === "number" + ? rawCurrentMessageId + : rawCurrentMessageId?.trim() || undefined; + return { + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + currentMessageId, + hasRepliedRef, + }; + }, }, }, whatsapp: { diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 4fce8fc5b3b..d88e2af49a9 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -673,6 +673,83 @@ describe("telegramMessageActions", () => { expect(String(callPayload.messageId)).toBe("456"); expect(callPayload.emoji).toBe("ok"); }); + + it("accepts snake_case message_id for reactions", async () => { + const cfg = telegramCfg(); + + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: { + channelId: 123, + message_id: "456", + emoji: "ok", + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action).toBe("react"); + expect(String(callPayload.chatId)).toBe("123"); + expect(String(callPayload.messageId)).toBe("456"); + }); + + it("falls back to toolContext.currentMessageId for reactions when messageId is omitted", async () => { + const cfg = telegramCfg(); + + await telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: { + chatId: "123", + emoji: "ok", + }, + cfg, + accountId: undefined, + toolContext: { currentMessageId: "9001" }, + }); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action).toBe("react"); + expect(String(callPayload.messageId)).toBe("9001"); + }); + + it("forwards missing reaction messageId to telegram-actions for soft-fail handling", async () => { + const cfg = telegramCfg(); + + await expect( + telegramMessageActions.handleAction?.({ + channel: "telegram", + action: "react", + params: { + chatId: "123", + emoji: "ok", + }, + cfg, + accountId: undefined, + }), + ).resolves.toBeDefined(); + + expect(handleTelegramAction).toHaveBeenCalledTimes(1); + const call = handleTelegramAction.mock.calls[0]?.[0]; + if (!call) { + throw new Error("missing telegram action call"); + } + const callPayload = call as Record; + expect(callPayload.action).toBe("react"); + expect(callPayload.messageId).toBeUndefined(); + }); }); describe("signalMessageActions", () => { diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 7328386848d..537ea2fee3c 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -107,7 +107,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, - handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => { + handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, toolContext }) => { if (action === "send") { const sendParams = readTelegramSendParams(params); return await handleTelegramAction( @@ -122,9 +122,8 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { } if (action === "react") { - const messageId = readStringOrNumberParam(params, "messageId", { - required: true, - }); + const messageId = + readStringOrNumberParam(params, "messageId") ?? toolContext?.currentMessageId; const emoji = readStringParam(params, "emoji", { allowEmpty: true }); const remove = typeof params.remove === "boolean" ? params.remove : undefined; return await handleTelegramAction( diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 6b8651e6c85..775fdef649e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -249,6 +249,7 @@ export type ChannelThreadingContext = { From?: string; To?: string; ChatType?: string; + CurrentMessageId?: string | number; ReplyToId?: string; ReplyToIdFull?: string; ThreadLabel?: string; @@ -259,6 +260,7 @@ export type ChannelThreadingToolContext = { currentChannelId?: string; currentChannelProvider?: ChannelId; currentThreadTs?: string; + currentMessageId?: string | number; replyToMode?: "off" | "first" | "all"; hasRepliedRef?: { value: boolean }; /** From 652099cd5cefa66e174f6a83646edc4f1d620513 Mon Sep 17 00:00:00 2001 From: Alice Losasso <104875499+dddabtc@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:32:53 -0400 Subject: [PATCH 026/314] fix: correctly identify Groq TPM limits as rate limits instead of context overflow (#16176) Co-authored-by: Howard --- src/agents/pi-embedded-helpers/errors.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 86ded785629..6a40f1d7b1d 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -39,6 +39,12 @@ export function isContextOverflowError(errorMessage?: string): boolean { return false; } const lower = errorMessage.toLowerCase(); + + // Groq uses 413 for TPM (tokens per minute) limits, which is a rate limit, not context overflow. + if (lower.includes("tpm") || lower.includes("tokens per minute")) { + return false; + } + const hasRequestSizeExceeds = lower.includes("request size exceeds"); const hasContextWindow = lower.includes("context window") || @@ -72,6 +78,13 @@ export function isLikelyContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) { return false; } + + // Groq uses 413 for TPM (tokens per minute) limits, which is a rate limit, not context overflow. + const lower = errorMessage.toLowerCase(); + if (lower.includes("tpm") || lower.includes("tokens per minute")) { + return false; + } + if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) { return false; } @@ -571,6 +584,8 @@ const ERROR_PATTERNS = { "quota exceeded", "resource_exhausted", "usage limit", + "tpm", + "tokens per minute", ], overloaded: [ /overloaded_error|"type"\s*:\s*"overloaded_error"/i, From 4f340b8812ded7b23e238dd921fad048afba189a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 10:38:49 -0500 Subject: [PATCH 027/314] fix(agents): avoid classifying reasoning-required errors as context overflow (#24593) * Agents: exclude reasoning-required errors from overflow detection * Tests: cover reasoning-required overflow classification guard * Tests: format reasoning-required endpoint errors --- ...d-helpers.formatassistanterrortext.test.ts | 9 ++++++ ...dded-helpers.isbillingerrormessage.test.ts | 22 +++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 3aedccefe79..397445067c1 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -35,6 +35,15 @@ describe("formatAssistantErrorText", () => { ); expect(formatAssistantErrorText(msg)).toContain("Context overflow"); }); + it("returns a reasoning-required message for mandatory reasoning endpoint errors", () => { + const msg = makeAssistantError( + "400 Reasoning is mandatory for this endpoint and cannot be disabled.", + ); + const result = formatAssistantErrorText(msg); + expect(result).toContain("Reasoning is required"); + expect(result).toContain("/think minimal"); + expect(result).not.toContain("Context overflow"); + }); it("returns a friendly message for Anthropic role ordering", () => { const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"'); expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict"); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 5900ec5dfc6..8b4b23ac6e9 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -208,6 +208,17 @@ describe("isContextOverflowError", () => { expect(isContextOverflowError("We're debugging context overflow issues")).toBe(false); expect(isContextOverflowError("Something is causing context overflow messages")).toBe(false); }); + + it("excludes reasoning-required invalid-request errors", () => { + const samples = [ + "400 Reasoning is mandatory for this endpoint and cannot be disabled.", + '{"type":"error","error":{"type":"invalid_request_error","message":"Reasoning is mandatory for this endpoint and cannot be disabled."}}', + "This model requires reasoning to be enabled", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(false); + } + }); }); describe("error classifiers", () => { @@ -286,6 +297,17 @@ describe("isLikelyContextOverflowError", () => { expect(isLikelyContextOverflowError(sample)).toBe(false); } }); + + it("excludes reasoning-required invalid-request errors", () => { + const samples = [ + "400 Reasoning is mandatory for this endpoint and cannot be disabled.", + '{"type":"error","error":{"type":"invalid_request_error","message":"Reasoning is mandatory for this endpoint and cannot be disabled."}}', + "This endpoint requires reasoning", + ]; + for (const sample of samples) { + expect(isLikelyContextOverflowError(sample)).toBe(false); + } + }); }); describe("isTransientHttpError", () => { diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6a40f1d7b1d..b4ccfa943b5 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -34,6 +34,19 @@ function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined { return undefined; } +function isReasoningConstraintErrorMessage(raw: string): boolean { + if (!raw) { + return false; + } + const lower = raw.toLowerCase(); + return ( + lower.includes("reasoning is mandatory") || + lower.includes("reasoning is required") || + lower.includes("requires reasoning") || + (lower.includes("reasoning") && lower.includes("cannot be disabled")) + ); +} + export function isContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) { return false; @@ -45,6 +58,10 @@ export function isContextOverflowError(errorMessage?: string): boolean { return false; } + if (isReasoningConstraintErrorMessage(errorMessage)) { + return false; + } + const hasRequestSizeExceeds = lower.includes("request size exceeds"); const hasContextWindow = lower.includes("context window") || @@ -85,6 +102,10 @@ export function isLikelyContextOverflowError(errorMessage?: string): boolean { return false; } + if (isReasoningConstraintErrorMessage(errorMessage)) { + return false; + } + if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) { return false; } @@ -464,6 +485,13 @@ export function formatAssistantErrorText( ); } + if (isReasoningConstraintErrorMessage(raw)) { + return ( + "Reasoning is required for this model endpoint. " + + "Use /think minimal (or any non-off level) and try again." + ); + } + // Catch role ordering errors - including JSON-wrapped and "400" prefix variants if ( /incorrect role information|roles must alternate|400.*role|"message".*role.*information/i.test( From 544809b6f6b034dacb32dbf3d5e3f6fda4bdb0b0 Mon Sep 17 00:00:00 2001 From: Clawborn Date: Mon, 23 Feb 2026 23:54:24 +0800 Subject: [PATCH 028/314] Add Chinese context overflow patterns to isContextOverflowError (#22855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy providers returning Chinese error messages (e.g. Chinese LLM gateways) use patterns like '上下文过长' or '上下文超出' that are not matched by the existing English-only patterns in isContextOverflowError. This prevents auto-compaction from triggering, leaving the session stuck. Add the most common Chinese proxy patterns: - 上下文过长 (context too long) - 上下文超出 (context exceeded) - 上下文长度超 (context length exceeds) - 超出最大上下文 (exceeds maximum context) - 请压缩上下文 (please compress context) Chinese characters are unaffected by toLowerCase() so check the original message directly. Closes #22849 --- ...-embedded-helpers.isbillingerrormessage.test.ts | 14 ++++++++++++++ src/agents/pi-embedded-helpers/errors.ts | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 8b4b23ac6e9..f4ae781e8c3 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -201,6 +201,20 @@ describe("isContextOverflowError", () => { } }); + it("matches Chinese context overflow error messages from proxy providers", () => { + const samples = [ + "上下文过长", + "错误:上下文过长,请减少输入", + "上下文超出限制", + "上下文长度超出模型最大限制", + "超出最大上下文长度", + "请压缩上下文后重试", + ]; + for (const sample of samples) { + expect(isContextOverflowError(sample)).toBe(true); + } + }); + it("ignores normal conversation text mentioning context overflow", () => { // These are legitimate conversation snippets, not error messages expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index b4ccfa943b5..80ba2219868 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -81,7 +81,13 @@ export function isContextOverflowError(errorMessage?: string): boolean { lower.includes("exceeds the model's maximum context") || (lower.includes("max_tokens") && lower.includes("exceed") && lower.includes("context")) || (lower.includes("input length") && lower.includes("exceed") && lower.includes("context")) || - (lower.includes("413") && lower.includes("too large")) + (lower.includes("413") && lower.includes("too large")) || + // Chinese proxy error messages for context overflow + errorMessage.includes("上下文过长") || + errorMessage.includes("上下文超出") || + errorMessage.includes("上下文长度超") || + errorMessage.includes("超出最大上下文") || + errorMessage.includes("请压缩上下文") ); } From eb4ff6df8165320d88c6a45747c5a780c9646990 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Mon, 23 Feb 2026 11:04:31 -0500 Subject: [PATCH 029/314] Allow Claude model requests to route through Google Vertex AI (#23985) * feat: add anthropic-vertex provider for Claude via GCP Vertex AI Co-Authored-By: Claude Opus 4.6 Signed-off-by: sallyom * docs: add anthropic-vertex provider guide Co-Authored-By: Claude Opus 4.6 Signed-off-by: sallyom * Agents: validate Anthropic Vertex project env * Changelog: format update for Vertex entry * Providers: rename Anthropic Vertex to Google Vertex Claude * Providers: remove Vertex Claude provider path * Models: normalize Vercel Claude shorthand refs * Onboarding: default Vercel model to Claude shorthand * Changelog: add @vincentkoc credit for #23985 * Onboarding: keep canonical Vercel default model ref * Tests: expand Vercel model normalization coverage --------- Signed-off-by: sallyom Co-authored-by: Claude Opus 4.6 Co-authored-by: Vincent Koc --- CHANGELOG.md | 2 ++ docs/providers/vercel-ai-gateway.md | 8 ++++++++ src/agents/model-selection.test.ts | 25 +++++++++++++++++++++++++ src/agents/model-selection.ts | 7 +++++++ 4 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 960ae605204..579c3945cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. + ### Breaking ### Fixes diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 726a6040fcc..3b5053fbac7 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -48,3 +48,11 @@ openclaw onboard --non-interactive \ If the Gateway runs as a daemon (launchd/systemd), make sure `AI_GATEWAY_API_KEY` is available to that process (for example, in `~/.openclaw/.env` or via `env.shellEnv`). + +## Model ID shorthand + +OpenClaw accepts Vercel Claude shorthand model refs and normalizes them at +runtime: + +- `vercel-ai-gateway/claude-opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4.6` +- `vercel-ai-gateway/opus-4.6` -> `vercel-ai-gateway/anthropic/claude-opus-4-6` diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index b903189b29a..df4298636c7 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -97,6 +97,31 @@ describe("model-selection", () => { }); }); + 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(); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 6f6e6d10f09..acdc2faf119 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -109,6 +109,13 @@ function normalizeProviderModelId(provider: string, model: string): string { if (provider === "anthropic") { return normalizeAnthropicModelId(model); } + if (provider === "vercel-ai-gateway" && !model.includes("/")) { + // Allow Vercel-specific Claude refs without an upstream prefix. + const normalizedAnthropicModel = normalizeAnthropicModelId(model); + if (normalizedAnthropicModel.startsWith("claude-")) { + return `anthropic/${normalizedAnthropicModel}`; + } + } if (provider === "google") { return normalizeGoogleModelId(model); } From 271a1490585796b9916b4785d04b3e5d61f4d522 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 23 Feb 2026 17:12:39 +0000 Subject: [PATCH 030/314] chore: add skills-lock.json to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cb28d086e6a..fca34f7d4ff 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,7 @@ package-lock.json .agents/ .agents .agent/ +skills-lock.json # Local iOS signing overrides apps/ios/LocalSigning.xcconfig From d6013929043ffa0e059c1e7bb190b6285d58f1e3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 12:13:37 -0500 Subject: [PATCH 031/314] Changelog: add PR #16176 entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579c3945cc2..91f66072e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. - Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. +- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. - Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. - Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. From 5e1dd5fe69ef224c334293f4cd4a639acbd50de0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 12:13:50 -0500 Subject: [PATCH 032/314] Changelog: add PR #24593 entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f66072e2f..319eefa2230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. - Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. +- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. - Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. - Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. From ae66a4b5d21f90e89eaa4de0f7a4bd6903d354cd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 12:14:04 -0500 Subject: [PATCH 033/314] Changelog: add PR #22855 entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 319eefa2230..6a358a780d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. - Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. +- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. - Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. - Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. From a8a4fa5b8883ddf4bbbb73c0f257aba4c5ba9770 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 17:19:25 +0000 Subject: [PATCH 034/314] test: de-duplicate attachment and bash tool tests --- extensions/msteams/src/attachments.test.ts | 744 +++++++++++---------- src/agents/bash-tools.test.ts | 298 +++++---- 2 files changed, 541 insertions(+), 501 deletions(-) diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 42470e370b6..71cddb08d62 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,5 +1,12 @@ import type { PluginRuntime } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, + buildMSTeamsMediaPayload, + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, +} from "./attachments.js"; import { setMSTeamsRuntime } from "./runtime.js"; vi.mock("openclaw/plugin-sdk", () => ({ @@ -52,13 +59,47 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -type AttachmentsModule = typeof import("./attachments.js"); -type DownloadAttachmentsParams = Parameters[0]; -type DownloadGraphMediaParams = Parameters[0]; +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; +type DownloadedMedia = Awaited>; +type DownloadAttachmentsBuildOverrides = Partial< + Omit +> & + Pick; +type DownloadAttachmentsNoFetchOverrides = Partial< + Omit< + DownloadAttachmentsParams, + "attachments" | "maxBytes" | "allowHosts" | "resolveFn" | "fetchFn" + > +> & + Pick; const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123"; const DEFAULT_MAX_BYTES = 1024 * 1024; const DEFAULT_ALLOW_HOSTS = ["x"]; +const IMAGE_ATTACHMENT = { contentType: "image/png", contentUrl: "https://x/img" }; +const PNG_BUFFER = Buffer.from("png"); +const PNG_BASE64 = PNG_BUFFER.toString("base64"); +const PDF_BUFFER = Buffer.from("pdf"); +const createTokenProvider = () => ({ getAccessToken: vi.fn(async () => "token") }); +const buildAttachment = >(contentType: string, props: T) => ({ + contentType, + ...props, +}); +const createHtmlAttachment = (content: string) => buildAttachment("text/html", { content }); +const createImageAttachment = (contentUrl: string) => buildAttachment("image/png", { contentUrl }); +const createPdfAttachment = (contentUrl: string) => + buildAttachment("application/pdf", { contentUrl }); +const createTeamsFileDownloadInfoAttachment = (downloadUrl = "https://x/dl", fileType = "png") => + buildAttachment("application/vnd.microsoft.teams.file.download.info", { + content: { downloadUrl, fileType }, + }); +const createImageMediaEntry = (path: string) => ({ path, contentType: "image/png" }); +const createHostedImageContent = (id: string) => ({ + id, + contentType: "image/png", + contentBytes: PNG_BASE64, +}); const createOkFetchMock = (contentType: string, payload = "png") => vi.fn(async () => { @@ -70,10 +111,7 @@ const createOkFetchMock = (contentType: string, payload = "png") => const buildDownloadParams = ( attachments: DownloadAttachmentsParams["attachments"], - overrides: Partial< - Omit - > & - Pick = {}, + overrides: DownloadAttachmentsBuildOverrides = {}, ): DownloadAttachmentsParams => { return { attachments, @@ -84,26 +122,188 @@ const buildDownloadParams = ( }; }; +const buildDownloadParamsWithFetch = ( + attachments: DownloadAttachmentsParams["attachments"], + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, +): DownloadAttachmentsParams => { + return buildDownloadParams(attachments, { + ...overrides, + fetchFn: fetchFn as unknown as typeof fetch, + }); +}; + +const downloadAttachmentsWithFetch = async ( + attachments: DownloadAttachmentsParams["attachments"], + fetchFn: unknown, + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: { expectFetchCalled?: boolean } = {}, +) => { + const media = await downloadMSTeamsAttachments( + buildDownloadParamsWithFetch(attachments, fetchFn, overrides), + ); + if (options.expectFetchCalled ?? true) { + expect(fetchFn).toHaveBeenCalled(); + } else { + expect(fetchFn).not.toHaveBeenCalled(); + } + return media; +}; +const downloadAttachmentsWithOkImageFetch = ( + attachments: DownloadAttachmentsParams["attachments"], + overrides: DownloadAttachmentsNoFetchOverrides = {}, + options: { expectFetchCalled?: boolean } = {}, +) => { + return downloadAttachmentsWithFetch( + attachments, + createOkFetchMock("image/png"), + overrides, + options, + ); +}; + +const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) => + vi.fn(async (_url: string, opts?: RequestInit) => { + const headers = new Headers(opts?.headers); + const hasAuth = Boolean(headers.get("Authorization")); + if (!hasAuth) { + return new Response(params.unauthBody, { status: params.unauthStatus }); + } + return new Response(PNG_BUFFER, { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + const buildDownloadGraphParams = ( - fetchFn: typeof fetch, + fetchFn: unknown, overrides: Partial< Omit > = {}, ): DownloadGraphMediaParams => { return { messageUrl: DEFAULT_MESSAGE_URL, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + tokenProvider: createTokenProvider(), maxBytes: DEFAULT_MAX_BYTES, - fetchFn, + fetchFn: fetchFn as unknown as typeof fetch, ...overrides, }; }; -describe("msteams attachments", () => { - const load = async () => { - return await import("./attachments.js"); - }; +const downloadGraphMediaWithFetch = ( + fetchFn: unknown, + overrides: Partial< + Omit + > = {}, +) => { + return downloadMSTeamsGraphMedia(buildDownloadGraphParams(fetchFn, overrides)); +}; +const expectFirstGraphUrlContains = ( + params: Parameters[0], + expectedPath: string, +) => { + const urls = buildMSTeamsGraphMessageUrls(params); + expect(urls[0]).toContain(expectedPath); +}; +const expectAttachmentPlaceholder = ( + attachments: Parameters[0], + expected: string, +) => { + expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected); +}; +type AttachmentPlaceholderCase = { + label: string; + attachments: Parameters[0]; + expected: string; +}; +type AttachmentDownloadSuccessCase = { + label: string; + attachments: DownloadAttachmentsParams["attachments"]; + assert?: (media: DownloadedMedia) => void; +}; +type AttachmentAuthRetryScenario = { + attachmentUrl: string; + unauthStatus: number; + unauthBody: string; + overrides?: Omit; +}; +type AttachmentAuthRetryCase = { + label: string; + scenario: AttachmentAuthRetryScenario; + expectedMediaLength: number; + expectTokenFetch: boolean; +}; +type GraphUrlExpectationCase = { + label: string; + params: Parameters[0]; + expectedPath: string; +}; +type GraphFetchMockOptions = { + hostedContents?: unknown[]; + attachments?: unknown[]; + messageAttachments?: unknown[]; + onShareRequest?: (url: string) => Response | Promise; + onUnhandled?: (url: string) => Response | Promise | undefined; +}; + +const createReferenceAttachment = (shareUrl: string) => ({ + id: "ref-1", + contentType: "reference", + contentUrl: shareUrl, + name: "report.pdf", +}); +const createShareReferenceFixture = (shareUrl = "https://contoso.sharepoint.com/site/file") => ({ + shareUrl, + referenceAttachment: createReferenceAttachment(shareUrl), +}); + +const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => { + const hostedContents = options.hostedContents ?? []; + const attachments = options.attachments ?? []; + const messageAttachments = options.messageAttachments ?? []; + return vi.fn(async (url: string) => { + if (url.endsWith("/hostedContents")) { + return new Response(JSON.stringify({ value: hostedContents }), { status: 200 }); + } + if (url.endsWith("/attachments")) { + return new Response(JSON.stringify({ value: attachments }), { status: 200 }); + } + if (url.endsWith("/messages/123")) { + return new Response(JSON.stringify({ attachments: messageAttachments }), { status: 200 }); + } + if (url.startsWith("https://graph.microsoft.com/v1.0/shares/") && options.onShareRequest) { + return options.onShareRequest(url); + } + const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined; + return unhandled ?? new Response("not found", { status: 404 }); + }); +}; +const downloadGraphMediaWithMockOptions = async ( + options: GraphFetchMockOptions = {}, + overrides: Partial< + Omit + > = {}, +) => { + const fetchMock = createGraphFetchMock(options); + const media = await downloadGraphMediaWithFetch(fetchMock, overrides); + return { fetchMock, media }; +}; +const runAttachmentAuthRetryScenario = async (scenario: AttachmentAuthRetryScenario) => { + const tokenProvider = createTokenProvider(); + const fetchMock = createAuthAwareImageFetchMock({ + unauthStatus: scenario.unauthStatus, + unauthBody: scenario.unauthBody, + }); + const media = await downloadAttachmentsWithFetch( + [createImageAttachment(scenario.attachmentUrl)], + fetchMock, + { tokenProvider, ...scenario.overrides }, + ); + return { tokenProvider, media }; +}; + +describe("msteams attachments", () => { beforeEach(() => { detectMimeMock.mockClear(); saveMediaBufferMock.mockClear(); @@ -112,112 +312,82 @@ describe("msteams attachments", () => { }); describe("buildMSTeamsAttachmentPlaceholder", () => { - it("returns empty string when no attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); - expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); - }); - - it("returns image placeholder for image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "image/png", contentUrl: "https://x/img.png" }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "image/png", contentUrl: "https://x/1.png" }, + it.each([ + { label: "returns empty string when no attachments", attachments: undefined, expected: "" }, + { label: "returns empty string when attachments are empty", attachments: [], expected: "" }, + { + label: "returns image placeholder for one image attachment", + attachments: [createImageAttachment("https://x/img.png")], + expected: "", + }, + { + label: "returns image placeholder with count for many image attachments", + attachments: [ + createImageAttachment("https://x/1.png"), { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" }, - ]), - ).toBe(" (2 images)"); - }); - - it("treats Teams file.download.info image attachments as images", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ]), - ).toBe(""); - }); - - it("returns document placeholder for non-image attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { contentType: "application/pdf", contentUrl: "https://x/1.pdf" }, - { contentType: "application/pdf", contentUrl: "https://x/2.pdf" }, - ]), - ).toBe(" (2 files)"); - }); - - it("counts inline images in text/html attachments", async () => { - const { buildMSTeamsAttachmentPlaceholder } = await load(); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '

hi

', - }, - ]), - ).toBe(""); - expect( - buildMSTeamsAttachmentPlaceholder([ - { - contentType: "text/html", - content: '', - }, - ]), - ).toBe(" (2 images)"); + ], + expected: " (2 images)", + }, + { + label: "treats Teams file.download.info image attachments as images", + attachments: [createTeamsFileDownloadInfoAttachment()], + expected: "", + }, + { + label: "returns document placeholder for non-image attachments", + attachments: [createPdfAttachment("https://x/x.pdf")], + expected: "", + }, + { + label: "returns document placeholder with count for many non-image attachments", + attachments: [ + createPdfAttachment("https://x/1.pdf"), + createPdfAttachment("https://x/2.pdf"), + ], + expected: " (2 files)", + }, + { + label: "counts one inline image in html attachments", + attachments: [createHtmlAttachment('

hi

')], + expected: "", + }, + { + label: "counts many inline images in html attachments", + attachments: [ + createHtmlAttachment(''), + ], + expected: " (2 images)", + }, + ])("$label", ({ attachments, expected }) => { + expectAttachmentPlaceholder(attachments, expected); }); }); describe("downloadMSTeamsAttachments", () => { - it("downloads and stores image contentUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = createOkFetchMock("image/png"); - const media = await downloadMSTeamsAttachments( - buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], { - fetchFn: fetchMock as unknown as typeof fetch, - }), - ); - - expect(fetchMock).toHaveBeenCalled(); - expect(saveMediaBufferMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - expect(media[0]?.path).toBe("/tmp/saved.png"); - }); - - it("supports Teams file.download.info downloadUrl attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = createOkFetchMock("image/png"); - const media = await downloadMSTeamsAttachments( - buildDownloadParams( - [ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ], - { fetchFn: fetchMock as unknown as typeof fetch }, - ), - ); - - expect(fetchMock).toHaveBeenCalled(); + it.each([ + { + label: "downloads and stores image contentUrl attachments", + attachments: [IMAGE_ATTACHMENT], + assert: (media) => { + expect(saveMediaBufferMock).toHaveBeenCalled(); + expect(media[0]?.path).toBe("/tmp/saved.png"); + }, + }, + { + label: "supports Teams file.download.info downloadUrl attachments", + attachments: [createTeamsFileDownloadInfoAttachment()], + }, + { + label: "downloads inline image URLs from html attachments", + attachments: [createHtmlAttachment('')], + }, + ])("$label", async ({ attachments, assert }) => { + const media = await downloadAttachmentsWithOkImageFetch(attachments); expect(media).toHaveLength(1); + assert?.(media); }); it("downloads non-image file attachments (PDF)", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = createOkFetchMock("application/pdf", "pdf"); detectMimeMock.mockResolvedValueOnce("application/pdf"); saveMediaBufferMock.mockResolvedValueOnce({ @@ -225,46 +395,20 @@ describe("msteams attachments", () => { contentType: "application/pdf", }); - const media = await downloadMSTeamsAttachments( - buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], { - fetchFn: fetchMock as unknown as typeof fetch, - }), + const media = await downloadAttachmentsWithFetch( + [createPdfAttachment("https://x/doc.pdf")], + fetchMock, ); - expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); expect(media[0]?.path).toBe("/tmp/saved.pdf"); expect(media[0]?.placeholder).toBe(""); }); - it("downloads inline image URLs from html attachments", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = createOkFetchMock("image/png"); - const media = await downloadMSTeamsAttachments( - buildDownloadParams( - [ - { - contentType: "text/html", - content: '', - }, - ], - { fetchFn: fetchMock as unknown as typeof fetch }, - ), - ); - - expect(media).toHaveLength(1); - expect(fetchMock).toHaveBeenCalled(); - }); - it("stores inline data:image base64 payloads", async () => { - const { downloadMSTeamsAttachments } = await load(); - const base64 = Buffer.from("png").toString("base64"); const media = await downloadMSTeamsAttachments( buildDownloadParams([ - { - contentType: "text/html", - content: ``, - }, + createHtmlAttachment(``), ]), ); @@ -272,218 +416,125 @@ describe("msteams attachments", () => { expect(saveMediaBufferMock).toHaveBeenCalled(); }); - it("retries with auth when the first request is unauthorized", async () => { - const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("unauthorized", { status: 401 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments( - buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], { - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - authAllowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - }), - ); - - expect(fetchMock).toHaveBeenCalled(); - expect(media).toHaveLength(1); - }); - - it("skips auth retries when the host is not in auth allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); - const tokenProvider = { getAccessToken: vi.fn(async () => "token") }; - const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { - const headers = new Headers(opts?.headers); - const hasAuth = Boolean(headers.get("Authorization")); - if (!hasAuth) { - return new Response("forbidden", { status: 403 }); - } - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments( - buildDownloadParams( - [{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }], - { - tokenProvider, + it.each([ + { + label: "retries with auth when the first request is unauthorized", + scenario: { + attachmentUrl: IMAGE_ATTACHMENT.contentUrl, + unauthStatus: 401, + unauthBody: "unauthorized", + overrides: { authAllowHosts: ["x"] }, + }, + expectedMediaLength: 1, + expectTokenFetch: true, + }, + { + label: "skips auth retries when the host is not in auth allowlist", + scenario: { + attachmentUrl: "https://attacker.azureedge.net/img", + unauthStatus: 403, + unauthBody: "forbidden", + overrides: { allowHosts: ["azureedge.net"], authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, }, - ), - ); - - expect(media).toHaveLength(0); - expect(fetchMock).toHaveBeenCalled(); - expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); + }, + expectedMediaLength: 0, + expectTokenFetch: false, + }, + ])("$label", async ({ scenario, expectedMediaLength, expectTokenFetch }) => { + const { tokenProvider, media } = await runAttachmentAuthRetryScenario(scenario); + expect(media).toHaveLength(expectedMediaLength); + if (expectTokenFetch) { + expect(tokenProvider.getAccessToken).toHaveBeenCalled(); + } else { + expect(tokenProvider.getAccessToken).not.toHaveBeenCalled(); + } }); it("skips urls outside the allowlist", async () => { - const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments( - buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], { + const media = await downloadAttachmentsWithFetch( + [createImageAttachment("https://evil.test/img")], + fetchMock, + { allowHosts: ["graph.microsoft.com"], resolveFn: undefined, - fetchFn: fetchMock as unknown as typeof fetch, - }), + }, + { expectFetchCalled: false }, ); expect(media).toHaveLength(0); - expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("buildMSTeamsGraphMessageUrls", () => { - it("builds channel message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - conversationId: "19:thread@thread.tacv2", - messageId: "123", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); - }); + const cases: GraphUrlExpectationCase[] = [ + { + label: "builds channel message urls", + params: { + conversationType: "channel" as const, + conversationId: "19:thread@thread.tacv2", + messageId: "123", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }, + expectedPath: "/teams/team-id/channels/chan-id/messages/123", + }, + { + label: "builds channel reply urls when replyToId is present", + params: { + conversationType: "channel" as const, + messageId: "reply-id", + replyToId: "root-id", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }, + expectedPath: "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", + }, + { + label: "builds chat message urls", + params: { + conversationType: "groupChat" as const, + conversationId: "19:chat@thread.v2", + messageId: "456", + }, + expectedPath: "/chats/19%3Achat%40thread.v2/messages/456", + }, + ]; - it("builds channel reply urls when replyToId is present", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "channel", - messageId: "reply-id", - replyToId: "root-id", - channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, - }); - expect(urls[0]).toContain( - "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", - ); - }); - - it("builds chat message urls", async () => { - const { buildMSTeamsGraphMessageUrls } = await load(); - const urls = buildMSTeamsGraphMessageUrls({ - conversationType: "groupChat", - conversationId: "19:chat@thread.v2", - messageId: "456", - }); - expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + it.each(cases)("$label", ({ params, expectedPath }) => { + expectFirstGraphUrlContains(params, expectedPath); }); }); describe("downloadMSTeamsGraphMedia", () => { it("downloads hostedContents images", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const base64 = Buffer.from("png").toString("base64"); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "1", - contentType: "image/png", - contentBytes: base64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - return new Response("not found", { status: 404 }); + const { fetchMock, media } = await downloadGraphMediaWithMockOptions({ + hostedContents: [createHostedImageContent("1")], }); - const media = await downloadMSTeamsGraphMedia( - buildDownloadGraphParams(fetchMock as unknown as typeof fetch), - ); - expect(media.media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); expect(saveMediaBufferMock).toHaveBeenCalled(); }); it("merges SharePoint reference attachments with hosted content", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const hostedBase64 = Buffer.from("png").toString("base64"); - const shareUrl = "https://contoso.sharepoint.com/site/file"; - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "hosted-1", - contentType: "image/png", - contentBytes: hostedBase64, - }, - ], - }), - { status: 200 }, - ); - } - if (url.endsWith("/attachments")) { - return new Response( - JSON.stringify({ - value: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(Buffer.from("pdf"), { + const { referenceAttachment } = createShareReferenceFixture(); + const { media } = await downloadGraphMediaWithMockOptions({ + hostedContents: [createHostedImageContent("hosted-1")], + attachments: [referenceAttachment], + messageAttachments: [referenceAttachment], + onShareRequest: () => + new Response(PDF_BUFFER, { status: 200, headers: { "content-type": "application/pdf" }, - }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], - }), - { status: 200 }, - ); - } - return new Response("not found", { status: 404 }); + }), }); - const media = await downloadMSTeamsGraphMedia( - buildDownloadGraphParams(fetchMock as unknown as typeof fetch), - ); - expect(media.media).toHaveLength(2); }); it("blocks SharePoint redirects to hosts outside allowHosts", async () => { - const { downloadMSTeamsGraphMedia } = await load(); - const shareUrl = "https://contoso.sharepoint.com/site/file"; + const { referenceAttachment } = createShareReferenceFixture(); const escapedUrl = "https://evil.example/internal.pdf"; fetchRemoteMediaMock.mockImplementationOnce(async (params) => { const fetchFn = params.fetchImpl ?? fetch; @@ -510,47 +561,27 @@ describe("msteams attachments", () => { throw new Error("too many redirects"); }); - const fetchMock = vi.fn(async (url: string) => { - if (url.endsWith("/hostedContents")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/attachments")) { - return new Response(JSON.stringify({ value: [] }), { status: 200 }); - } - if (url.endsWith("/messages/123")) { - return new Response( - JSON.stringify({ - attachments: [ - { - id: "ref-1", - contentType: "reference", - contentUrl: shareUrl, - name: "report.pdf", - }, - ], + const { fetchMock, media } = await downloadGraphMediaWithMockOptions( + { + messageAttachments: [referenceAttachment], + onShareRequest: () => + new Response(null, { + status: 302, + headers: { location: escapedUrl }, }), - { status: 200 }, - ); - } - if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { - return new Response(null, { - status: 302, - headers: { location: escapedUrl }, - }); - } - if (url === escapedUrl) { - return new Response(Buffer.from("should-not-be-fetched"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - } - return new Response("not found", { status: 404 }); - }); - - const media = await downloadMSTeamsGraphMedia( - buildDownloadGraphParams(fetchMock as unknown as typeof fetch, { + onUnhandled: (url) => { + if (url === escapedUrl) { + return new Response(Buffer.from("should-not-be-fetched"), { + status: 200, + headers: { "content-type": "application/pdf" }, + }); + } + return undefined; + }, + }, + { allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - }), + }, ); expect(media.media).toHaveLength(0); @@ -564,10 +595,9 @@ describe("msteams attachments", () => { describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", async () => { - const { buildMSTeamsMediaPayload } = await load(); const payload = buildMSTeamsMediaPayload([ - { path: "/tmp/a.png", contentType: "image/png" }, - { path: "/tmp/b.png", contentType: "image/png" }, + createImageMediaEntry("/tmp/a.png"), + createImageMediaEntry("/tmp/b.png"), ]); expect(payload.MediaPath).toBe("/tmp/a.png"); expect(payload.MediaUrl).toBe("/tmp/a.png"); diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 14f6f5fffcf..5006d8e8611 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -15,10 +15,26 @@ const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 4" : "sleep 0.004"; const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 16" : "sleep 0.016"; const longDelayCmd = isWin ? "Start-Sleep -Milliseconds 72" : "sleep 0.072"; const POLL_INTERVAL_MS = 15; +const BACKGROUND_POLL_TIMEOUT_MS = isWin ? 8000 : 1200; +const NOTIFY_EVENT_TIMEOUT_MS = isWin ? 12_000 : 5_000; const TEST_EXEC_DEFAULTS = { security: "full" as const, ask: "off" as const }; +const DEFAULT_NOTIFY_SESSION_KEY = "agent:main:main"; +type ExecToolConfig = Exclude[0], undefined>; const createTestExecTool = ( defaults?: Parameters[0], ): ReturnType => createExecTool({ ...TEST_EXEC_DEFAULTS, ...defaults }); +const createNotifyOnExitExecTool = (overrides: Partial = {}) => + createTestExecTool({ + allowBackground: true, + backgroundMs: 0, + notifyOnExit: true, + sessionKey: DEFAULT_NOTIFY_SESSION_KEY, + ...overrides, + }); +const createScopedToolSet = (scopeKey: string) => ({ + exec: createTestExecTool({ backgroundMs: 10, scopeKey }), + process: createProcessTool({ scopeKey }), +}); const execTool = createTestExecTool(); const processTool = createProcessTool(); // Both PowerShell and bash use ; for command separation @@ -33,13 +49,36 @@ const normalizeText = (value?: string) => .map((line) => line.replace(/\s+$/u, "")) .join("\n") .trim(); +type ToolTextContent = Array<{ type: string; text?: string }>; +const readTextContent = (content: ToolTextContent) => + content.find((part) => part.type === "text")?.text; +const readNormalizedTextContent = (content: ToolTextContent) => + normalizeText(readTextContent(content)); +const readTrimmedLines = (content: ToolTextContent) => + (readTextContent(content) ?? "").split("\n").map((line) => line.trim()); +const readTotalLines = (details: unknown) => (details as { totalLines?: number }).totalLines; -function captureShellEnv() { - const envSnapshot = captureEnv(["SHELL"]); +function applyDefaultShellEnv() { if (!isWin && defaultShell) { process.env.SHELL = defaultShell; } - return envSnapshot; +} + +function useCapturedEnv(keys: string[], afterCapture?: () => void) { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = captureEnv(keys); + afterCapture?.(); + }); + + afterEach(() => { + envSnapshot.restore(); + }); +} + +function useCapturedShellEnv() { + useCapturedEnv(["SHELL"], applyDefaultShellEnv); } async function waitForCompletion(sessionId: string) { @@ -54,18 +93,42 @@ async function waitForCompletion(sessionId: string) { status = (poll.details as { status: string }).status; return status; }, - { timeout: process.platform === "win32" ? 8000 : 1200, interval: POLL_INTERVAL_MS }, + { timeout: BACKGROUND_POLL_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, ) .not.toBe("running"); return status; } -async function runBackgroundEchoLines(lines: string[]) { - const result = await execTool.execute("call1", { - command: echoLines(lines), +function requireSessionId(details: { sessionId?: string }): string { + if (!details.sessionId) { + throw new Error("expected sessionId in exec result details"); + } + return details.sessionId; +} + +function hasNotifyEventForPrefix(prefix: string): boolean { + return peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY).some((event) => event.includes(prefix)); +} + +async function startBackgroundSession(params: { + tool: ReturnType; + callId: string; + command: string; +}) { + const result = await params.tool.execute(params.callId, { + command: params.command, background: true, }); - const sessionId = (result.details as { sessionId: string }).sessionId; + expect(result.details.status).toBe("running"); + return requireSessionId(result.details as { sessionId?: string }); +} + +async function runBackgroundEchoLines(lines: string[]) { + const sessionId = await startBackgroundSession({ + tool: execTool, + callId: "call1", + command: echoLines(lines), + }); await waitForCompletion(sessionId); return sessionId; } @@ -81,18 +144,32 @@ async function readProcessLog( }); } +type ProcessLogResult = Awaited>; +const readLogSnapshot = (log: ProcessLogResult) => ({ + text: readTextContent(log.content) ?? "", + lines: readTrimmedLines(log.content), + totalLines: readTotalLines(log.details), +}); +const createNumberedLines = (count: number) => + Array.from({ length: count }, (_value, index) => `line-${index + 1}`); +const LONG_LOG_LINE_COUNT = 201; + +async function runBackgroundAndReadProcessLog( + lines: string[], + options: { offset?: number; limit?: number } = {}, +) { + const sessionId = await runBackgroundEchoLines(lines); + return readProcessLog(sessionId, options); +} +const readLongProcessLog = (options: { offset?: number; limit?: number } = {}) => + runBackgroundAndReadProcessLog(createNumberedLines(LONG_LOG_LINE_COUNT), options); + async function runBackgroundAndWaitForCompletion(params: { tool: ReturnType; callId: string; command: string; }) { - const result = await params.tool.execute(params.callId, { - command: params.command, - background: true, - }); - - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; + const sessionId = await startBackgroundSession(params); const status = await waitForCompletion(sessionId); expect(status).toBe("completed"); return { sessionId }; @@ -104,15 +181,7 @@ beforeEach(() => { }); describe("exec tool backgrounding", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureShellEnv(); - }); - - afterEach(() => { - envSnapshot.restore(); - }); + useCapturedShellEnv(); it( "backgrounds after yield and can be polled", @@ -122,8 +191,15 @@ describe("exec tool backgrounding", () => { yieldMs: 10, }); + // Timing can race here: command may already be complete before the first response. + if (result.details.status === "completed") { + const text = readTextContent(result.content) ?? ""; + expect(text).toContain("done"); + return; + } + expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; + const sessionId = requireSessionId(result.details as { sessionId?: string }); let output = ""; await expect @@ -134,11 +210,10 @@ describe("exec tool backgrounding", () => { sessionId, }); const status = (poll.details as { status: string }).status; - const textBlock = poll.content.find((c) => c.type === "text"); - output = textBlock?.text ?? ""; + output = readTextContent(poll.content) ?? ""; return status; }, - { timeout: process.platform === "win32" ? 8000 : 1200, interval: POLL_INTERVAL_MS }, + { timeout: BACKGROUND_POLL_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, ) .toBe("completed"); @@ -148,14 +223,12 @@ describe("exec tool backgrounding", () => { ); it("supports explicit background and derives session name from the command", async () => { - const result = await execTool.execute("call1", { + const sessionId = await startBackgroundSession({ + tool: execTool, + callId: "call1", command: "echo hello", - background: true, }); - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - const list = await processTool.execute("call2", { action: "list" }); const sessions = (list.details as { sessions: Array<{ sessionId: string; name?: string }> }) .sessions; @@ -180,7 +253,7 @@ describe("exec tool backgrounding", () => { const customBash = createTestExecTool({ elevated: { enabled: true, allowed: false, defaultLevel: "off" }, messageProvider: "telegram", - sessionKey: "agent:main:main", + sessionKey: DEFAULT_NOTIFY_SESSION_KEY, }); await expect( @@ -201,99 +274,66 @@ describe("exec tool backgrounding", () => { const result = await customBash.execute("call1", { command: "echo hi", }); - const text = result.content.find((c) => c.type === "text")?.text ?? ""; + const text = readTextContent(result.content) ?? ""; expect(text).toContain("hi"); }); it("logs line-based slices and defaults to last lines", async () => { - const result = await execTool.execute("call1", { + const { sessionId } = await runBackgroundAndWaitForCompletion({ + tool: execTool, + callId: "call1", command: echoLines(["one", "two", "three"]), - background: true, }); - const sessionId = (result.details as { sessionId: string }).sessionId; - const status = await waitForCompletion(sessionId); - - const log = await processTool.execute("call3", { - action: "log", - sessionId, - limit: 2, - }); - const textBlock = log.content.find((c) => c.type === "text"); - expect(normalizeText(textBlock?.text)).toBe("two\nthree"); - expect((log.details as { totalLines?: number }).totalLines).toBe(3); - expect(status).toBe("completed"); + const log = await readProcessLog(sessionId, { limit: 2 }); + expect(readNormalizedTextContent(log.content)).toBe("two\nthree"); + expect(readTotalLines(log.details)).toBe(3); }); it("applies default tail only when no explicit log window is provided", async () => { - const lines = Array.from({ length: 201 }, (_value, index) => `line-${index + 1}`); - const sessionId = await runBackgroundEchoLines(lines); - - const log = await readProcessLog(sessionId); - const textBlock = log.content.find((c) => c.type === "text")?.text ?? ""; - const firstLine = textBlock.split("\n")[0]?.trim(); - expect(textBlock).toContain("showing last 200 of 201 lines"); - expect(firstLine).toBe("line-2"); - expect(textBlock).toContain("line-2"); - expect(textBlock).toContain("line-201"); - expect((log.details as { totalLines?: number }).totalLines).toBe(201); + const snapshot = readLogSnapshot(await readLongProcessLog()); + expect(snapshot.text).toContain("showing last 200 of 201 lines"); + expect(snapshot.lines[0]).toBe("line-2"); + expect(snapshot.text).toContain("line-2"); + expect(snapshot.text).toContain("line-201"); + expect(snapshot.totalLines).toBe(LONG_LOG_LINE_COUNT); }); it("supports line offsets for log slices", async () => { - const result = await execTool.execute("call1", { - command: echoLines(["alpha", "beta", "gamma"]), - background: true, - }); - const sessionId = (result.details as { sessionId: string }).sessionId; - await waitForCompletion(sessionId); + const sessionId = await runBackgroundEchoLines(["alpha", "beta", "gamma"]); - const log = await processTool.execute("call2", { - action: "log", - sessionId, - offset: 1, - limit: 1, - }); - const textBlock = log.content.find((c) => c.type === "text"); - expect(normalizeText(textBlock?.text)).toBe("beta"); + const log = await readProcessLog(sessionId, { offset: 1, limit: 1 }); + expect(readNormalizedTextContent(log.content)).toBe("beta"); }); it("keeps offset-only log requests unbounded by default tail mode", async () => { - const lines = Array.from({ length: 201 }, (_value, index) => `line-${index + 1}`); - const sessionId = await runBackgroundEchoLines(lines); - - const log = await readProcessLog(sessionId, { offset: 30 }); - - const textBlock = log.content.find((c) => c.type === "text")?.text ?? ""; - const renderedLines = textBlock.split("\n"); - expect(renderedLines[0]?.trim()).toBe("line-31"); - expect(renderedLines[renderedLines.length - 1]?.trim()).toBe("line-201"); - expect(textBlock).not.toContain("showing last 200"); - expect((log.details as { totalLines?: number }).totalLines).toBe(201); + const snapshot = readLogSnapshot(await readLongProcessLog({ offset: 30 })); + expect(snapshot.lines[0]).toBe("line-31"); + expect(snapshot.lines[snapshot.lines.length - 1]).toBe("line-201"); + expect(snapshot.text).not.toContain("showing last 200"); + expect(snapshot.totalLines).toBe(LONG_LOG_LINE_COUNT); }); it("scopes process sessions by scopeKey", async () => { - const bashA = createTestExecTool({ backgroundMs: 10, scopeKey: "agent:alpha" }); - const processA = createProcessTool({ scopeKey: "agent:alpha" }); - const bashB = createTestExecTool({ backgroundMs: 10, scopeKey: "agent:beta" }); - const processB = createProcessTool({ scopeKey: "agent:beta" }); + const alphaTools = createScopedToolSet("agent:alpha"); + const betaTools = createScopedToolSet("agent:beta"); - const resultA = await bashA.execute("call1", { + const sessionA = await startBackgroundSession({ + tool: alphaTools.exec, + callId: "call1", command: shortDelayCmd, - background: true, }); - const resultB = await bashB.execute("call2", { + const sessionB = await startBackgroundSession({ + tool: betaTools.exec, + callId: "call2", command: shortDelayCmd, - background: true, }); - const sessionA = (resultA.details as { sessionId: string }).sessionId; - const sessionB = (resultB.details as { sessionId: string }).sessionId; - - const listA = await processA.execute("call3", { action: "list" }); + const listA = await alphaTools.process.execute("call3", { action: "list" }); const sessionsA = (listA.details as { sessions: Array<{ sessionId: string }> }).sessions; expect(sessionsA.some((s) => s.sessionId === sessionA)).toBe(true); expect(sessionsA.some((s) => s.sessionId === sessionB)).toBe(false); - const pollB = await processB.execute("call4", { + const pollB = await betaTools.process.execute("call4", { action: "poll", sessionId: sessionA, }); @@ -303,15 +343,7 @@ describe("exec tool backgrounding", () => { }); describe("exec exit codes", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureShellEnv(); - }); - - afterEach(() => { - envSnapshot.restore(); - }); + useCapturedShellEnv(); it("treats non-zero exits as completed and appends exit code", async () => { const command = isWin @@ -322,7 +354,7 @@ describe("exec exit codes", () => { expect(resultDetails.status).toBe("completed"); expect(resultDetails.exitCode).toBe(1); - const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + const text = readNormalizedTextContent(result.content); expect(text).toContain("nope"); expect(text).toContain("Command exited with code 1"); }); @@ -330,39 +362,32 @@ describe("exec exit codes", () => { describe("exec notifyOnExit", () => { it("enqueues a system event when a backgrounded exec exits", async () => { - const tool = createTestExecTool({ - allowBackground: true, - backgroundMs: 0, - notifyOnExit: true, - sessionKey: "agent:main:main", - }); + const tool = createNotifyOnExitExecTool(); - const result = await tool.execute("call1", { + const sessionId = await startBackgroundSession({ + tool, + callId: "call1", command: echoAfterDelay("notify"), - background: true, }); - expect(result.details.status).toBe("running"); - const sessionId = (result.details as { sessionId: string }).sessionId; - const prefix = sessionId.slice(0, 8); let finished = getFinishedSession(sessionId); - let hasEvent = peekSystemEvents("agent:main:main").some((event) => event.includes(prefix)); + let hasEvent = hasNotifyEventForPrefix(prefix); await expect .poll( () => { finished = getFinishedSession(sessionId); - hasEvent = peekSystemEvents("agent:main:main").some((event) => event.includes(prefix)); + hasEvent = hasNotifyEventForPrefix(prefix); return Boolean(finished && hasEvent); }, - { timeout: isWin ? 12_000 : 5_000, interval: POLL_INTERVAL_MS }, + { timeout: NOTIFY_EVENT_TIMEOUT_MS, interval: POLL_INTERVAL_MS }, ) .toBe(true); if (!finished) { finished = getFinishedSession(sessionId); } if (!hasEvent) { - hasEvent = peekSystemEvents("agent:main:main").some((event) => event.includes(prefix)); + hasEvent = hasNotifyEventForPrefix(prefix); } expect(finished).toBeTruthy(); @@ -381,20 +406,16 @@ describe("exec notifyOnExit", () => { }, ]) { resetSystemEventsForTest(); - const tool = createTestExecTool({ - allowBackground: true, - backgroundMs: 0, - notifyOnExit: true, - ...(testCase.notifyOnExitEmptySuccess ? { notifyOnExitEmptySuccess: true } : {}), - sessionKey: "agent:main:main", - }); + const tool = createNotifyOnExitExecTool( + testCase.notifyOnExitEmptySuccess ? { notifyOnExitEmptySuccess: true } : {}, + ); await runBackgroundAndWaitForCompletion({ tool, callId: "call-noop", command: shortDelayCmd, }); - const events = peekSystemEvents("agent:main:main"); + const events = peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY); if (!testCase.notifyOnExitEmptySuccess) { expect(events, testCase.label).toEqual([]); } else { @@ -409,18 +430,7 @@ describe("exec notifyOnExit", () => { }); describe("exec PATH handling", () => { - let envSnapshot: ReturnType; - - beforeEach(() => { - envSnapshot = captureEnv(["PATH", "SHELL"]); - if (!isWin && defaultShell) { - process.env.SHELL = defaultShell; - } - }); - - afterEach(() => { - envSnapshot.restore(); - }); + useCapturedEnv(["PATH", "SHELL"], applyDefaultShellEnv); it("prepends configured path entries", async () => { const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin"; @@ -432,7 +442,7 @@ describe("exec PATH handling", () => { command: isWin ? "Write-Output $env:PATH" : "echo $PATH", }); - const text = normalizeText(result.content.find((c) => c.type === "text")?.text); + const text = readNormalizedTextContent(result.content); const entries = text.split(path.delimiter); expect(entries.slice(0, prepend.length)).toEqual(prepend); expect(entries).toContain(basePath); From f7e45ce947f5c0293610458ccabce1a8f39d7e2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 17:19:25 +0000 Subject: [PATCH 035/314] test: consolidate trigger-handling status and heartbeat scenarios --- ...age-summary-current-model-provider.test.ts | 104 ++++----- ...targets-active-session-native-stop.test.ts | 204 +++++++++--------- 2 files changed, 136 insertions(+), 172 deletions(-) diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts index 584799e18db..ef26242d199 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts @@ -122,15 +122,19 @@ describe("trigger handling", () => { ); }); }); - it("emits /status once (no duplicate inline + final)", async () => { + it("reports /status once without invoking the agent", async () => { await withTempHome(async (home) => { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); const { blockReplies, replies } = await runCommandAndCollectReplies({ home, body: "/status", }); expect(blockReplies.length).toBe(0); expect(replies.length).toBe(1); - expect(String(replies[0]?.text ?? "")).toContain("Model:"); + const text = String(replies[0]?.text ?? ""); + expect(text).toContain("Model:"); + expect(text).toContain("OpenClaw"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("sets per-response usage footer via /usage", async () => { @@ -255,39 +259,22 @@ describe("trigger handling", () => { expect(prompt).not.toContain("/status"); }); }); - it("aborts even with timestamp prefix", async () => { + it("handles /stop command variants without invoking the agent", async () => { await withTempHome(async (home) => { - await expectStopAbortWithoutAgent({ - home, - body: "[Dec 5 10:00] stop", - from: "+1000", - }); - }); - }); - it("handles /stop without invoking the agent", async () => { - await withTempHome(async (home) => { - await expectStopAbortWithoutAgent({ - home, - body: "/stop", - from: "+1003", - }); + for (const testCase of [ + { body: "[Dec 5 10:00] stop", from: "+1000" }, + { body: "/stop", from: "+1003" }, + ] as const) { + await expectStopAbortWithoutAgent({ home, body: testCase.body, from: testCase.from }); + } }); }); - it("shows endpoint default in /model status when not configured", async () => { - await withTempHome(async (home) => { - const cfg = makeCfg(home); - const res = await getReplyFromConfig(modelStatusCtx, {}, cfg); - - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(normalizeTestText(text ?? "")).toContain("endpoint: default"); - }); - }); - - it("includes endpoint details in /model status when configured", async () => { + it("shows model status defaults and configured endpoint details", async () => { await withTempHome(async (home) => { + const defaultCfg = makeCfg(home); const cfg = { - ...makeCfg(home), + ...defaultCfg, models: { providers: { minimax: { @@ -297,20 +284,27 @@ describe("trigger handling", () => { }, }, } as unknown as OpenClawConfig; - const res = await getReplyFromConfig(modelStatusCtx, {}, cfg); + const defaultStatus = await getReplyFromConfig(modelStatusCtx, {}, defaultCfg); + const configuredStatus = await getReplyFromConfig(modelStatusCtx, {}, cfg); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - const normalized = normalizeTestText(text ?? ""); - expect(normalized).toContain( + expect( + normalizeTestText( + (Array.isArray(defaultStatus) ? defaultStatus[0]?.text : defaultStatus?.text) ?? "", + ), + ).toContain("endpoint: default"); + const configuredText = Array.isArray(configuredStatus) + ? configuredStatus[0]?.text + : configuredStatus?.text; + expect(normalizeTestText(configuredText ?? "")).toContain( "[minimax] endpoint: https://api.minimax.io/anthropic api: anthropic-messages auth:", ); }); }); - it("restarts by default", async () => { + it("restarts by default and rejects /restart when disabled", async () => { await withTempHome(async (home) => { const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const res = await getReplyFromConfig( + const enabledRes = await getReplyFromConfig( { Body: " [Dec 5] /restart", From: "+1001", @@ -320,17 +314,13 @@ describe("trigger handling", () => { {}, makeCfg(home), ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text?.startsWith("⚙️ Restarting") || text?.startsWith("⚠️ Restart failed")).toBe(true); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); + const enabledText = Array.isArray(enabledRes) ? enabledRes[0]?.text : enabledRes?.text; + expect( + enabledText?.startsWith("⚙️ Restarting") || enabledText?.startsWith("⚠️ Restart failed"), + ).toBe(true); - it("rejects /restart when explicitly disabled", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const cfg = { ...makeCfg(home), commands: { restart: false } } as OpenClawConfig; - const res = await getReplyFromConfig( + const disabledCfg = { ...makeCfg(home), commands: { restart: false } } as OpenClawConfig; + const disabledRes = await getReplyFromConfig( { Body: "/restart", From: "+1001", @@ -338,29 +328,11 @@ describe("trigger handling", () => { CommandAuthorized: true, }, {}, - cfg, + disabledCfg, ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("/restart is disabled"); - expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - }); - }); - it("reports status without invoking the agent", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - const res = await getReplyFromConfig( - { - Body: "/status", - From: "+1002", - To: "+2000", - CommandAuthorized: true, - }, - {}, - makeCfg(home), - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("OpenClaw"); + const disabledText = Array.isArray(disabledRes) ? disabledRes[0]?.text : disabledRes?.text; + expect(disabledText).toContain("/restart is disabled"); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts index 4c40064b269..c6967192457 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; @@ -91,72 +90,66 @@ describe("trigger handling", () => { }); }); - it("uses heartbeat model override for heartbeat runs", async () => { + it("uses heartbeat override when configured and falls back to stored model override", async () => { await withTempHome(async (home) => { const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); - const cfg = makeCfg(home); - await writeStoredModelOverride(cfg); - cfg.agents = { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + const cases = [ + { + label: "heartbeat-override", + setup: (cfg: ReturnType) => { + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + }, + }; + }, + expected: { provider: "anthropic", model: "claude-haiku-4-5-20251001" }, }, - }; + { + label: "stored-override", + setup: () => undefined, + expected: { provider: "openai", model: "gpt-5.2" }, + }, + ] as const; - await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); + for (const testCase of cases) { + runEmbeddedPiAgentMock.mockClear(); + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: join(home, `${testCase.label}.sessions.json`) }; + await writeStoredModelOverride(cfg); + testCase.setup(cfg); + await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(call?.provider).toBe("anthropic"); - expect(call?.model).toBe("claude-haiku-4-5-20251001"); + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; + expect(call?.provider).toBe(testCase.expected.provider); + expect(call?.model).toBe(testCase.expected.model); + } }); }); - it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = mockEmbeddedOkPayload(); - const cfg = makeCfg(home); - await writeStoredModelOverride(cfg); - await getReplyFromConfig(BASE_MESSAGE, { isHeartbeat: true }, cfg); - - const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; - expect(call?.provider).toBe("openai"); - expect(call?.model).toBe("gpt-5.2"); - }); - }); - - it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { + it("suppresses or strips HEARTBEAT_OK replies outside heartbeat runs", async () => { await withTempHome(async (home) => { const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: HEARTBEAT_TOKEN }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); + const cases = [ + { text: HEARTBEAT_TOKEN, expected: undefined }, + { text: `${HEARTBEAT_TOKEN} hello`, expected: "hello" }, + ] as const; - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - expect(res).toBeUndefined(); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); - }); - }); - - it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => { - await withTempHome(async (home) => { - const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); - runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); - - expect(maybeReplyText(res)).toBe("hello"); + for (const testCase of cases) { + runEmbeddedPiAgentMock.mockClear(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: testCase.text }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const res = await getReplyFromConfig(BASE_MESSAGE, {}, makeCfg(home)); + expect(maybeReplyText(res)).toBe(testCase.expected); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + } }); }); @@ -187,60 +180,59 @@ describe("trigger handling", () => { }); }); - it("runs /compact as a gated command", async () => { + it("runs /compact for main and non-default agents without invoking the embedded run path", async () => { await withTempHome(async (home) => { - const storePath = join(tmpdir(), `openclaw-session-test-${Date.now()}.json`); - const cfg = makeCfg(home); - cfg.session = { ...cfg.session, store: storePath }; - mockSuccessfulCompaction(); + { + const storePath = join(home, "compact-main.sessions.json"); + const cfg = makeCfg(home); + cfg.session = { ...cfg.session, store: storePath }; + mockSuccessfulCompaction(); - const request = { - Body: "/compact focus on decisions", - From: "+1003", - To: "+2000", - }; - - const res = await getReplyFromConfig( - { - ...request, - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = maybeReplyText(res); - expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); - const store = loadSessionStore(storePath); - const sessionKey = resolveSessionKey("per-sender", request); - expect(store[sessionKey]?.compactionCount).toBe(1); - }); - }); - - it("runs /compact for non-default agents without transcript path validation failures", async () => { - await withTempHome(async (home) => { - getCompactEmbeddedPiSessionMock().mockClear(); - mockSuccessfulCompaction(); - - const res = await getReplyFromConfig( - { - Body: "/compact", - From: "+1004", + const request = { + Body: "/compact focus on decisions", + From: "+1003", To: "+2000", - SessionKey: "agent:worker1:telegram:12345", - CommandAuthorized: true, - }, - {}, - makeCfg(home), - ); + }; + + const res = await getReplyFromConfig( + { + ...request, + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = maybeReplyText(res); + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + const store = loadSessionStore(storePath); + const sessionKey = resolveSessionKey("per-sender", request); + expect(store[sessionKey]?.compactionCount).toBe(1); + } + + { + getCompactEmbeddedPiSessionMock().mockClear(); + mockSuccessfulCompaction(); + const res = await getReplyFromConfig( + { + Body: "/compact", + From: "+1004", + To: "+2000", + SessionKey: "agent:worker1:telegram:12345", + CommandAuthorized: true, + }, + {}, + makeCfg(home), + ); + + const text = maybeReplyText(res); + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); + expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain( + join("agents", "worker1", "sessions"), + ); + } - const text = maybeReplyText(res); - expect(text?.startsWith("⚙️ Compacted")).toBe(true); - expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce(); - expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain( - join("agents", "worker1", "sessions"), - ); expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); }); }); From fdd185cfaa2c58b4571cd32f02b4ee91cdeda9b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Feb 2026 17:19:33 +0000 Subject: [PATCH 036/314] test: merge inline trigger command and elevated coverage --- ...ne-commands-strips-it-before-agent.test.ts | 142 +++++++++--------- 1 file changed, 73 insertions(+), 69 deletions(-) diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts index ff9521c9799..02320977fb8 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts @@ -110,25 +110,54 @@ async function runInlineUnauthorizedCommand(params: { } describe("trigger handling", () => { - it("allows /activation from allowFrom in groups", async () => { + it("handles owner-admin commands without invoking the agent", async () => { await withTempHome(async (home) => { - const cfg = makeCfg(home); - const res = await getReplyFromConfig( - { - Body: "/activation mention", - From: "123@g.us", - To: "+2000", - ChatType: "group", - Provider: "whatsapp", - SenderE164: "+999", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toBe("⚙️ Group activation set to mention."); - expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled(); + { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockClear(); + const cfg = makeCfg(home); + const res = await getReplyFromConfig( + { + Body: "/activation mention", + From: "123@g.us", + To: "+2000", + ChatType: "group", + Provider: "whatsapp", + SenderE164: "+999", + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("⚙️ Group activation set to mention."); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + } + + { + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockClear(); + const cfg = makeUnauthorizedWhatsAppCfg(home); + const res = await getReplyFromConfig( + { + Body: "/send off", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + CommandAuthorized: true, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Send policy set to off"); + + const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); + const store = JSON.parse(storeRaw) as Record; + expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + } }); }); @@ -275,31 +304,6 @@ describe("trigger handling", () => { }); }); - it("allows owner to set send policy", async () => { - await withTempHome(async (home) => { - const cfg = makeUnauthorizedWhatsAppCfg(home); - - const res = await getReplyFromConfig( - { - Body: "/send off", - From: "+1000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+1000", - CommandAuthorized: true, - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Send policy set to off"); - - const storeRaw = await fs.readFile(requireSessionStorePath(cfg), "utf-8"); - const store = JSON.parse(storeRaw) as Record; - expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny"); - }); - }); - it("enforces elevated toggles across enabled and mention scenarios", async () => { await withTempHome(async (home) => { const isolateStore = (cfg: ReturnType, label: string) => { @@ -409,34 +413,34 @@ describe("trigger handling", () => { expect(text).toBeUndefined(); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); } - }); - }); - it("ignores inline elevated directive for unapproved sender", async () => { - await withTempHome(async (home) => { - getRunEmbeddedPiAgentMock().mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const cfg = makeWhatsAppElevatedCfg(home); + { + const cfg = isolateStore(makeWhatsAppElevatedCfg(home), "inline-unapproved"); + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockClear(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); - const res = await getReplyFromConfig( - { - Body: "please /elevated on now", - From: "+2000", - To: "+2000", - Provider: "whatsapp", - SenderE164: "+2000", - }, - {}, - cfg, - ); - const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).not.toContain("elevated is not available right now"); - expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalled(); + const res = await getReplyFromConfig( + { + Body: "please /elevated on now", + From: "+2000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+2000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).not.toContain("elevated is not available right now"); + expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); + } }); }); From 28377e1b7a03e9a33ded16d4c77a0dbc4671b7cb Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 23 Feb 2026 12:27:17 -0500 Subject: [PATCH 037/314] UI: add version status pill before Health in web header (#24648) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f240589d33fd891efd99558c412d927aa9323908 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- ui/src/i18n/locales/en.ts | 1 + ui/src/i18n/locales/pt-BR.ts | 1 + ui/src/i18n/locales/zh-CN.ts | 1 + ui/src/i18n/locales/zh-TW.ts | 1 + ui/src/styles/components.css | 6 ++++++ ui/src/ui/app-render.ts | 21 ++++++++++++++++++--- ui/src/ui/gateway.ts | 4 ++++ 7 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index a54f31e583a..dfba6d21fa8 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts"; export const en: TranslationMap = { common: { + version: "Version", health: "Health", ok: "OK", offline: "Offline", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 6c34f2317bf..d7cb780bb5f 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts"; export const pt_BR: TranslationMap = { common: { + version: "Versão", health: "Saúde", ok: "OK", offline: "Offline", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index e757b0ef8f9..f6c7ce38c85 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts"; export const zh_CN: TranslationMap = { common: { + version: "版本", health: "健康状况", ok: "正常", offline: "离线", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index d0d8e141f27..52f39b92398 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts"; export const zh_TW: TranslationMap = { common: { + version: "版本", health: "健康狀況", ok: "正常", offline: "離線", diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 428f5f9a9d5..701da6b2ab9 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -328,6 +328,12 @@ animation: none; } +.statusDot.warn { + background: var(--warn); + box-shadow: 0 0 8px rgba(245, 158, 11, 0.5); + animation: none; +} + /* =========================================== Buttons - Tactile with personality =========================================== */ diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index d4f8fee89bf..487ba0bbc53 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -136,6 +136,16 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { + const openClawVersion = + (typeof state.hello?.server?.version === "string" && state.hello.server.version.trim()) || + state.updateAvailable?.currentVersion || + t("common.na"); + const availableUpdate = + state.updateAvailable && + state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion + ? state.updateAvailable + : null; + const versionStatusClass = availableUpdate ? "warn" : "ok"; const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; @@ -231,6 +241,11 @@ export function renderApp(state: AppViewState) {
+
+ + ${t("common.version")} + ${openClawVersion} +
${t("common.health")} @@ -286,10 +301,10 @@ export function renderApp(state: AppViewState) {
${ - state.updateAvailable + availableUpdate ? html`
Date:${header?.timestamp ? new Date(header.timestamp).toLocaleString() : "unknown"}
-
Models:${globalStats.models.join(", ") || "unknown"}
+
Models:${escapeHtml(globalStats.models.join(", ") || "unknown")}
Messages:${msgParts.join(", ") || "0"}
Tool Calls:${globalStats.toolCalls}
Tokens:${tokenParts.join(" ") || "0"}
@@ -1718,6 +1730,10 @@ codespan(token) { return `${escapeHtml(token.text)}`; }, + // Raw HTML blocks/inline HTML: escape to prevent script execution. + html(token) { + return escapeHtml(token.text); + }, }, }); diff --git a/src/auto-reply/reply/export-html/template.security.test.ts b/src/auto-reply/reply/export-html/template.security.test.ts new file mode 100644 index 00000000000..2837df7036b --- /dev/null +++ b/src/auto-reply/reply/export-html/template.security.test.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parseHTML } from "linkedom"; + +type SessionEntry = { + id: string; + parentId: string | null; + timestamp: string; + type: string; + message?: unknown; + summary?: string; + content?: unknown; + display?: boolean; + customType?: string; + provider?: string; + modelId?: string; + thinkingLevel?: string; +}; + +type SessionData = { + header: { id: string; timestamp: string }; + entries: SessionEntry[]; + leafId: string; + systemPrompt: string; + tools: unknown[]; +}; + +const exportHtmlDir = path.dirname(fileURLToPath(import.meta.url)); +const templateHtml = fs.readFileSync(path.join(exportHtmlDir, "template.html"), "utf8"); +const templateJs = fs.readFileSync(path.join(exportHtmlDir, "template.js"), "utf8"); +const markedJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "marked.min.js"), "utf8"); +const highlightJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "highlight.min.js"), "utf8"); + +function renderTemplate(sessionData: SessionData) { + const html = templateHtml + .replace("{{CSS}}", "") + .replace("{{SESSION_DATA}}", Buffer.from(JSON.stringify(sessionData), "utf8").toString("base64")) + .replace("{{MARKED_JS}}", "") + .replace("{{HIGHLIGHT_JS}}", "") + .replace("{{JS}}", ""); + + const { document, window } = parseHTML(html); + if (window.HTMLElement?.prototype) { + window.HTMLElement.prototype.scrollIntoView = () => {}; + } + + const immediateTimeout = (fn: (...args: unknown[]) => void) => { + fn(); + return 0; + }; + const runtime: Record = { + document, + console, + clearTimeout: () => {}, + setTimeout: immediateTimeout, + URLSearchParams, + TextDecoder, + atob: (s: string) => Buffer.from(s, "base64").toString("binary"), + btoa: (s: string) => Buffer.from(s, "binary").toString("base64"), + navigator: { clipboard: { writeText: async () => {} } }, + history: { replaceState: () => {} }, + location: { href: "http://localhost/export.html", search: "" }, + }; + runtime.window = runtime; + runtime.self = runtime; + runtime.globalThis = runtime; + + vm.createContext(runtime); + vm.runInContext(markedJs, runtime); + vm.runInContext(highlightJs, runtime); + vm.runInContext(templateJs, runtime); + return { document }; +} + +function now() { + return new Date("2026-02-24T00:00:00.000Z").toISOString(); +} + +describe("export html security hardening", () => { + it("escapes raw HTML from markdown blocks", () => { + const attack = ""; + const session: SessionData = { + header: { id: "session-1", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { role: "user", content: attack }, + }, + { + id: "2", + parentId: "1", + timestamp: now(), + type: "branch_summary", + summary: attack, + }, + { + id: "3", + parentId: "2", + timestamp: now(), + type: "custom_message", + customType: "x", + display: true, + content: attack, + }, + ], + leafId: "3", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(session); + const messages = document.getElementById("messages"); + expect(messages).toBeTruthy(); + expect(messages?.querySelector("img[onerror]")).toBeNull(); + expect(messages?.innerHTML).toContain("<img src=x onerror=alert(1)>"); + }); + + it("escapes tree and header metadata fields", () => { + const attack = ""; + const baseEntries: SessionEntry[] = [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { role: "user", content: "ok" }, + }, + { + id: "2", + parentId: "1", + timestamp: now(), + type: "message", + message: { + role: "assistant", + model: attack, + provider: "p", + content: [{ type: "text", text: "assistant" }], + }, + }, + { + id: "3", + parentId: "2", + timestamp: now(), + type: "message", + message: { role: "toolResult", toolName: attack }, + }, + { + id: "4", + parentId: "3", + timestamp: now(), + type: "model_change", + provider: "p", + modelId: attack, + }, + { + id: "5", + parentId: "4", + timestamp: now(), + type: "thinking_level_change", + thinkingLevel: attack, + }, + { + id: "6", + parentId: "5", + timestamp: now(), + type: attack, + }, + ]; + + const headerSession: SessionData = { + header: { id: "session-2", timestamp: now() }, + entries: baseEntries, + leafId: "6", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(headerSession); + const tree = document.getElementById("tree-container"); + const header = document.getElementById("header-container"); + expect(tree).toBeTruthy(); + expect(header).toBeTruthy(); + expect(tree?.querySelector("img[onerror]")).toBeNull(); + expect(header?.querySelector("img[onerror]")).toBeNull(); + expect(tree?.innerHTML).toContain("<img src=x onerror=alert(9)>"); + expect(header?.innerHTML).toContain("<img src=x onerror=alert(9)>"); + + const modelLeafSession: SessionData = { + header: { id: "session-2-model", timestamp: now() }, + entries: baseEntries, + leafId: "4", + systemPrompt: "", + tools: [], + }; + const modelLeaf = renderTemplate(modelLeafSession).document; + expect(modelLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull(); + expect(modelLeaf.getElementById("tree-container")?.innerHTML).toContain( + "<img src=x onerror=alert(9)>", + ); + + const thinkingLeafSession: SessionData = { + header: { id: "session-2-thinking", timestamp: now() }, + entries: baseEntries, + leafId: "5", + systemPrompt: "", + tools: [], + }; + const thinkingLeaf = renderTemplate(thinkingLeafSession).document; + expect(thinkingLeaf.getElementById("tree-container")?.querySelector("img[onerror]")).toBeNull(); + expect(thinkingLeaf.getElementById("tree-container")?.innerHTML).toContain( + "<img src=x onerror=alert(9)>", + ); + }); + + it("sanitizes image MIME types used in data URLs", () => { + const session: SessionData = { + header: { id: "session-3", timestamp: now() }, + entries: [ + { + id: "1", + parentId: null, + timestamp: now(), + type: "message", + message: { + role: "user", + content: [ + { + type: "image", + data: "AAAA", + mimeType: 'image/png" onerror="alert(7)', + }, + ], + }, + }, + ], + leafId: "1", + systemPrompt: "", + tools: [], + }; + + const { document } = renderTemplate(session); + const img = document.querySelector("#messages .message-image"); + expect(img).toBeTruthy(); + expect(img?.getAttribute("onerror")).toBeNull(); + expect(img?.getAttribute("src")).toBe("data:application/octet-stream;base64,AAAA"); + }); +}); From ff4e6ca0d942ef52330dcbe116321ae4fed21749 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:51:27 +0000 Subject: [PATCH 166/314] fix(ios): gate agent deep links with local confirmation --- CHANGELOG.md | 1 + .../Gateway/DeepLinkAgentPromptAlert.swift | 40 ++ apps/ios/Sources/Model/NodeAppModel.swift | 380 ++++++++++++------ apps/ios/Sources/RootCanvas.swift | 1 + apps/ios/Tests/NodeAppModelInvokeTests.swift | 81 +++- 5 files changed, 371 insertions(+), 132 deletions(-) create mode 100644 apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5e5934250..0c1aa1c2589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. +- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift new file mode 100644 index 00000000000..0624e976b51 --- /dev/null +++ b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DeepLinkAgentPromptAlert: ViewModifier { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + + private var promptBinding: Binding { + Binding( + get: { self.appModel.pendingAgentDeepLinkPrompt }, + set: { _ in + // Keep prompt state until explicit user action. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Run OpenClaw agent?"), + message: Text( + """ + Message: + \(prompt.messagePreview) + + URL: + \(prompt.urlPreview) + """), + primaryButton: .cancel(Text("Cancel")) { + self.appModel.declinePendingAgentDeepLinkPrompt() + }, + secondaryButton: .default(Text("Run")) { + Task { await self.appModel.approvePendingAgentDeepLinkPrompt() } + }) + } + } +} + +extension View { + func deepLinkAgentPromptAlert() -> some View { + self.modifier(DeepLinkAgentPromptAlert()) + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5bd98e6f492..fc5e6097b18 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -3,6 +3,7 @@ import OpenClawKit import OpenClawProtocol import Observation import os +import Security import SwiftUI import UIKit import UserNotifications @@ -37,9 +38,22 @@ private final class NotificationInvokeLatch: @unchecked Sendable { cont?.resume(returning: response) } } + +private enum IOSDeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 +} + @MainActor @Observable final class NodeAppModel { + struct AgentDeepLinkPrompt: Identifiable, Equatable { + let id: String + let messagePreview: String + let urlPreview: String + let request: AgentDeepLink + } + private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") @@ -74,6 +88,8 @@ final class NodeAppModel { var gatewayAgents: [AgentSummary] = [] var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 + private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var lastAgentDeepLinkPromptAt: Date = .distantPast // Primary "node" connection: used for device capabilities and node.invoke requests. private let nodeGateway = GatewayNodeSession() @@ -485,21 +501,14 @@ final class NodeAppModel { } } - private func applyMainSessionKey(_ key: String?) { - let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == current { return } - self.mainSessionBaseKey = trimmed - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - var seamColor: Color { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" + private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" + private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() private static var apnsEnvironment: String { #if DEBUG "sandbox" @@ -508,17 +517,6 @@ final class NodeAppModel { #endif } - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } - private func refreshBrandingFromGateway() async { do { let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) @@ -699,117 +697,6 @@ final class NodeAppModel { self.gatewayHealthMonitor.stop() } - private func refreshWakeWordsFromGateway() async { - do { - let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } - VoiceWakePreferences.saveTriggerWords(triggers) - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") || lower.contains("missing scope") { - await self.setGatewayHealthMonitorDisabled(true) - return - } - } - // Best-effort only. - } - } - - private func isGatewayHealthMonitorDisabled() -> Bool { - self.gatewayHealthMonitorDisabled - } - - private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { - self.gatewayHealthMonitorDisabled = disabled - } - - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - if await !self.isGatewayConnected() { - throw NSError(domain: "Gateway", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) - } - - func handleDeepLink(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case let .agent(link): - await self.handleAgentDeepLink(link, originalURL: url) - case .gateway: - break - } - } - - private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { - let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - self.deepLinkLogger.info( - "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" - ) - - if message.count > 20000 { - self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)." - self.recordShareEvent("Rejected: message too large (\(message.count) chars).") - return - } - - guard await self.isGatewayConnected() else { - self.screen.errorText = "Gateway not connected (cannot forward deep link)." - self.recordShareEvent("Failed: gateway not connected.") - self.deepLinkLogger.error("agent deep link rejected: gateway not connected") - return - } - - do { - try await self.sendAgentRequest(link: link) - self.screen.errorText = nil - self.recordShareEvent("Sent to gateway (\(message.count) chars).") - self.deepLinkLogger.info("agent deep link forwarded to gateway") - self.openChatRequestID &+= 1 - } catch { - self.screen.errorText = "Agent request failed: \(error.localizedDescription)" - self.recordShareEvent("Failed: \(error.localizedDescription)") - self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func sendAgentRequest(link: AgentDeepLink) async throws { - if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw NSError(domain: "DeepLink", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "invalid agent message", - ]) - } - - // iOS gateway forwards to the gateway; no local auth prompts here. - // (Key-based unattended auth is handled on macOS for openclaw:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) - } - - private func isGatewayConnected() async -> Bool { - self.gatewayConnected - } - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command @@ -2560,6 +2447,229 @@ extension NodeAppModel { } } +extension NodeAppModel { + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return + } + } + // Best-effort only. + } + } + + private func isGatewayHealthMonitorDisabled() -> Bool { + self.gatewayHealthMonitorDisabled + } + + private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { + self.gatewayHealthMonitorDisabled = disabled + } + + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) + } + + func handleDeepLink(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case let .agent(link): + await self.handleAgentDeepLink(link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { + let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + self.deepLinkLogger.info( + "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" + ) + + if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { + self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)." + self.recordShareEvent("Rejected: message too large (\(message.count) chars).") + return + } + + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link rejected: gateway not connected") + return + } + + let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key) + if !allowUnattended { + if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars { + self.screen.errorText = "Deep link blocked (message too long without key)." + self.recordShareEvent( + "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.") + self.deepLinkLogger.error( + "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") + return + } + if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { + self.deepLinkLogger.debug("agent deep link prompt throttled") + return + } + self.lastAgentDeepLinkPromptAt = Date() + + let urlText = originalURL.absoluteString + let prompt = AgentDeepLinkPrompt( + id: UUID().uuidString, + messagePreview: message, + urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, + request: self.effectiveAgentDeepLinkForPrompt(link)) + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") + self.deepLinkLogger.info("agent deep link requires local confirmation") + return + } + + await self.submitAgentDeepLink(link, messageCharCount: message.count) + } + + private func sendAgentRequest(link: AgentDeepLink) async throws { + if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw NSError(domain: "DeepLink", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "invalid agent message", + ]) + } + + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + } + + private func isGatewayConnected() async -> Bool { + self.gatewayConnected + } + + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == current { return } + self.mainSessionBaseKey = trimmed + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + func approvePendingAgentDeepLinkPrompt() async { + guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + self.pendingAgentDeepLinkPrompt = nil + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link approval failed: gateway not connected") + return + } + await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count) + } + + func declinePendingAgentDeepLinkPrompt() { + guard self.pendingAgentDeepLinkPrompt != nil else { return } + self.pendingAgentDeepLinkPrompt = nil + self.screen.errorText = "Deep link cancelled." + self.recordShareEvent("Cancelled: deep link confirmation declined.") + self.deepLinkLogger.info("agent deep link cancelled by local user") + } + + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { + do { + try await self.sendAgentRequest(link: link) + self.screen.errorText = nil + self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).") + self.deepLinkLogger.info("agent deep link forwarded to gateway") + self.openChatRequestID &+= 1 + } catch { + self.screen.errorText = "Agent request failed: \(error.localizedDescription)" + self.recordShareEvent("Failed: \(error.localizedDescription)") + self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink { + // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk. + AgentDeepLink( + message: link.message, + sessionKey: link.sessionKey, + thinking: link.thinking, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: link.timeoutSeconds, + key: link.key) + } + + private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool { + let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedKey.isEmpty else { return false } + return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey() + } + + private static func expectedDeepLinkKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + return key + } + let key = self.generateDeepLinkKey() + defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey) + return key + } + + private static func generateDeepLinkKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + extension NodeAppModel { func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { await self.handleWatchQuickReply(event) @@ -2607,5 +2717,13 @@ extension NodeAppModel { func _test_queuedWatchReplyCount() -> Int { self.queuedWatchReplies.count } + + func _test_setGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + } + + static func _test_currentDeepLinkKey() -> String { + self.expectedDeepLinkKey() + } } #endif diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index da893d3c943..dd0f389ed4d 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -88,6 +88,7 @@ struct RootCanvas: View { } } .gatewayTrustPromptAlert() + .deepLinkAgentPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 3d015afae84..24bc4ba0639 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -29,8 +29,35 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +private func makeAgentDeepLinkURL( + message: String, + deliver: Bool = false, + to: String? = nil, + channel: String? = nil, + key: String? = nil) -> URL +{ + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] + if deliver { + queryItems.append(URLQueryItem(name: "deliver", value: "1")) + } + if let to { + queryItems.append(URLQueryItem(name: "to", value: to)) + } + if let channel { + queryItems.append(URLQueryItem(name: "channel", value: channel)) + } + if let key { + queryItems.append(URLQueryItem(name: "key", value: key)) + } + components.queryItems = queryItems + return components.url! +} + @MainActor -private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable { +private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { var currentStatus = WatchMessagingStatus( supported: true, paired: true, @@ -327,6 +354,58 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } + @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL(message: "hello from deep link") + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt != nil) + #expect(appModel.openChatRequestID == 0) + + await appModel.approvePendingAgentDeepLinkPrompt() + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL( + message: "route this", + deliver: true, + to: "123456", + channel: "telegram") + + await appModel.handleDeepLink(url: url) + let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) + #expect(prompt.request.deliver == false) + #expect(prompt.request.to == nil) + #expect(prompt.request.channel == nil) + } + + @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let message = String(repeating: "x", count: 241) + let url = makeAgentDeepLinkURL(message: message) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.screen.errorText?.contains("blocked") == true) + } + + @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let key = NodeAppModel._test_currentDeepLinkKey() + let url = makeAgentDeepLinkURL(message: "trusted request", key: key) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { From fefc414576cb72084d2d4c69a0c5ef1d5bb7930c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:52:25 +0000 Subject: [PATCH 167/314] fix(security): harden structural session path fallback --- src/config/sessions.test.ts | 37 ++++++++++++++++++++++++++++ src/config/sessions/paths.ts | 47 ++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 5e66d36237d..26696d60ac7 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -561,6 +561,43 @@ describe("sessions", () => { }); }); + it("falls back when structural cross-root path traverses after sessions", () => { + withStateDir(path.resolve("/different/state"), () => { + const originalBase = path.resolve("/original/state"); + const unsafe = path.join(originalBase, "agents", "bot2", "sessions", "..", "..", "etc"); + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: path.join(unsafe, "passwd") }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe( + path.join(path.resolve("/different/state"), "agents", "bot1", "sessions", "sess-1.jsonl"), + ); + }); + }); + + it("falls back when structural cross-root path nests under sessions", () => { + withStateDir(path.resolve("/different/state"), () => { + const originalBase = path.resolve("/original/state"); + const nested = path.join( + originalBase, + "agents", + "bot2", + "sessions", + "nested", + "sess-1.jsonl", + ); + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: nested }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe( + path.join(path.resolve("/different/state"), "agents", "bot1", "sessions", "sess-1.jsonl"), + ); + }); + }); + it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => { withStateDir(path.resolve("/home/user/.openclaw"), () => { const sessionFile = resolveSessionFilePath( diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 53e6c9c19f0..0d3c0d6a2ab 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -115,6 +115,39 @@ function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string return agentId || undefined; } +function resolveStructuralSessionFallbackPath( + candidateAbsPath: string, + expectedAgentId: string, +): string | undefined { + const normalized = path.normalize(path.resolve(candidateAbsPath)); + const parts = normalized.split(path.sep).filter(Boolean); + const sessionsIndex = parts.lastIndexOf("sessions"); + if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") { + return undefined; + } + const agentIdPart = parts[sessionsIndex - 1]; + if (!agentIdPart) { + return undefined; + } + const normalizedAgentId = normalizeAgentId(agentIdPart); + if (normalizedAgentId !== agentIdPart.toLowerCase()) { + return undefined; + } + if (normalizedAgentId !== normalizeAgentId(expectedAgentId)) { + return undefined; + } + const relativeSegments = parts.slice(sessionsIndex + 1); + // Session transcripts are stored as direct files in "sessions/". + if (relativeSegments.length !== 1) { + return undefined; + } + const fileName = relativeSegments[0]; + if (!fileName || fileName === "." || fileName === "..") { + return undefined; + } + return normalized; +} + function safeRealpathSync(filePath: string): string | undefined { try { return fs.realpathSync(filePath); @@ -170,11 +203,15 @@ function resolvePathWithinSessionsDir( if (resolvedFromPath) { return resolvedFromPath; } - // The path structurally matches .../agents//sessions/... - // Accept it even if the root directory differs from the current env - // (e.g., OPENCLAW_STATE_DIR changed between session creation and resolution). - // The structural pattern provides sufficient containment guarantees. - return path.resolve(realTrimmed); + // Cross-root compatibility for older absolute paths: + // keep only canonical .../agents//sessions/ shapes. + const structuralFallback = resolveStructuralSessionFallbackPath( + realTrimmed, + extractedAgentId, + ); + if (structuralFallback) { + return structuralFallback; + } } } if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) { From e578521ef4930d02c573fa2d9ef72c4317a34dd6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:52:33 +0000 Subject: [PATCH 168/314] fix(security): harden session export image data-url handling --- CHANGELOG.md | 1 + src/agents/tool-images.test.ts | 18 +++++++++ src/agents/tool-images.ts | 13 ++++++- src/auto-reply/reply/export-html/template.js | 34 ++++++++++++----- src/media/base64.test.ts | 18 +++++++++ src/media/base64.ts | 14 +++++++ src/media/input-files.fetch-guard.test.ts | 39 ++++++++++++++++++++ src/media/input-files.ts | 16 ++++++-- 8 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 src/media/base64.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1aa1c2589..a0f15aaa71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. ## 2026.2.23 (Unreleased) diff --git a/src/agents/tool-images.test.ts b/src/agents/tool-images.test.ts index 6de86b0e4bd..83c6a0adbba 100644 --- a/src/agents/tool-images.test.ts +++ b/src/agents/tool-images.test.ts @@ -107,4 +107,22 @@ describe("tool image sanitizing", () => { const image = getImageBlock(out); expect(image.mimeType).toBe("image/jpeg"); }); + + it("drops malformed image base64 payloads", async () => { + const blocks = [ + { + type: "image" as const, + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)', + mimeType: "image/png", + }, + ]; + + const out = await sanitizeContentBlocksImages(blocks, "test"); + expect(out).toEqual([ + { + type: "text", + text: "[test] omitted image payload: invalid base64", + }, + ]); + }); }); diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index a72fed30c28..e2019570a31 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { canonicalizeBase64 } from "../media/base64.js"; import { buildImageResizeSideGrid, getImageMetadata, @@ -296,13 +297,21 @@ export async function sanitizeContentBlocksImages( } satisfies TextContentBlock); continue; } + const canonicalData = canonicalizeBase64(data); + if (!canonicalData) { + out.push({ + type: "text", + text: `[${label}] omitted image payload: invalid base64`, + } satisfies TextContentBlock); + continue; + } try { - const inferredMimeType = inferMimeTypeFromBase64(data); + const inferredMimeType = inferMimeTypeFromBase64(canonicalData); const mimeType = inferredMimeType ?? block.mimeType; const fileName = inferImageFileName({ block, label, mediaPathHint }); const resized = await resizeImageBase64IfNeeded({ - base64: data, + base64: canonicalData, mimeType, maxDimensionPx, maxBytes, diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js index 318751fde53..565eeda7f65 100644 --- a/src/auto-reply/reply/export-html/template.js +++ b/src/auto-reply/reply/export-html/template.js @@ -665,15 +665,36 @@ return div.innerHTML; } - // Validate image MIME type to prevent attribute injection in data-URL src. + // Validate image fields before interpolating data URLs. const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i; + const SAFE_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + function sanitizeImageMimeType(mimeType) { if (typeof mimeType === "string" && SAFE_IMAGE_MIME_RE.test(mimeType)) { - return mimeType; + return mimeType.toLowerCase(); } return "application/octet-stream"; } + function sanitizeImageBase64(data) { + if (typeof data !== "string") { + return ""; + } + const cleaned = data.replace(/\s+/g, ""); + if (!cleaned || cleaned.length % 4 !== 0 || !SAFE_BASE64_RE.test(cleaned)) { + return ""; + } + return cleaned; + } + + function renderDataUrlImage(img, className) { + const mimeType = sanitizeImageMimeType(img?.mimeType); + const base64 = sanitizeImageBase64(img?.data); + if (!base64) { + return ""; + } + return ``; + } /** * Truncate string to maxLen chars, append "..." if truncated. */ @@ -1037,12 +1058,7 @@ } return ( '
' + - images - .map( - (img) => - ``, - ) - .join("") + + images.map((img) => renderDataUrlImage(img, "tool-image")).join("") + "
" ); }; @@ -1315,7 +1331,7 @@ if (images.length > 0) { html += '
'; for (const img of images) { - html += ``; + html += renderDataUrlImage(img, "message-image"); } html += "
"; } diff --git a/src/media/base64.test.ts b/src/media/base64.test.ts new file mode 100644 index 00000000000..7888bea4578 --- /dev/null +++ b/src/media/base64.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js"; + +describe("base64 helpers", () => { + it("normalizes whitespace and keeps valid base64", () => { + const input = " SGV s bG8= \n"; + expect(canonicalizeBase64(input)).toBe("SGVsbG8="); + }); + + it("rejects invalid base64 characters", () => { + const input = 'SGVsbG8=" onerror="alert(1)'; + expect(canonicalizeBase64(input)).toBeUndefined(); + }); + + it("estimates decoded bytes with whitespace", () => { + expect(estimateBase64DecodedBytes("SGV s bG8= \n")).toBe(5); + }); +}); diff --git a/src/media/base64.ts b/src/media/base64.ts index 56a8626c37b..aa81ae5d295 100644 --- a/src/media/base64.ts +++ b/src/media/base64.ts @@ -35,3 +35,17 @@ export function estimateBase64DecodedBytes(base64: string): number { const estimated = Math.floor((effectiveLen * 3) / 4) - padding; return Math.max(0, estimated); } + +const BASE64_CHARS_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +/** + * Normalize and validate a base64 string. + * Returns canonical base64 (no whitespace) or undefined when invalid. + */ +export function canonicalizeBase64(base64: string): string | undefined { + const cleaned = base64.replace(/\s+/g, ""); + if (!cleaned || cleaned.length % 4 !== 0 || !BASE64_CHARS_RE.test(cleaned)) { + return undefined; + } + return cleaned; +} diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 0b293e5cf42..64f8377bcfd 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -113,3 +113,42 @@ describe("base64 size guards", () => { fromSpy.mockRestore(); }); }); + +describe("input image base64 validation", () => { + it("rejects malformed base64 payloads", async () => { + await expect( + extractImageContentFromSource( + { + type: "base64", + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2N4j8AAAAASUVORK5CYII=" onerror="alert(1)', + mediaType: "image/png", + }, + { + allowUrl: false, + allowedMimes: new Set(["image/png"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1, + }, + ), + ).rejects.toThrow("invalid 'data' field"); + }); + + it("normalizes whitespace in valid base64 payloads", async () => { + const image = await extractImageContentFromSource( + { + type: "base64", + data: " aGVs bG8= \n", + mediaType: "image/png", + }, + { + allowUrl: false, + allowedMimes: new Set(["image/png"]), + maxBytes: 1024 * 1024, + maxRedirects: 0, + timeoutMs: 1, + }, + ); + expect(image.data).toBe("aGVsbG8="); + }); +}); diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 61fc067ef9b..b6d2aa837aa 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -1,7 +1,7 @@ import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { logWarn } from "../logger.js"; -import { estimateBase64DecodedBytes } from "./base64.js"; +import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js"; import { readResponseWithLimit } from "./read-response-with-limit.js"; type CanvasModule = typeof import("@napi-rs/canvas"); @@ -309,17 +309,21 @@ export async function extractImageContentFromSource( throw new Error("input_image base64 source missing 'data' field"); } rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "Image" }); + const canonicalData = canonicalizeBase64(source.data); + if (!canonicalData) { + throw new Error("input_image base64 source has invalid 'data' field"); + } const mimeType = normalizeMimeType(source.mediaType) ?? "image/png"; if (!limits.allowedMimes.has(mimeType)) { throw new Error(`Unsupported image MIME type: ${mimeType}`); } - const buffer = Buffer.from(source.data, "base64"); + const buffer = Buffer.from(canonicalData, "base64"); if (buffer.byteLength > limits.maxBytes) { throw new Error( `Image too large: ${buffer.byteLength} bytes (limit: ${limits.maxBytes} bytes)`, ); } - return { type: "image", data: source.data, mimeType }; + return { type: "image", data: canonicalData, mimeType }; } if (source.type === "url" && source.url) { @@ -362,10 +366,14 @@ export async function extractFileContentFromSource(params: { throw new Error("input_file base64 source missing 'data' field"); } rejectOversizedBase64Payload({ data: source.data, maxBytes: limits.maxBytes, label: "File" }); + const canonicalData = canonicalizeBase64(source.data); + if (!canonicalData) { + throw new Error("input_file base64 source has invalid 'data' field"); + } const parsed = parseContentType(source.mediaType); mimeType = parsed.mimeType; charset = parsed.charset; - buffer = Buffer.from(source.data, "base64"); + buffer = Buffer.from(canonicalData, "base64"); } else if (source.type === "url" && source.url) { if (!limits.allowUrl) { throw new Error("input_file URL sources are disabled by config"); From 90383e00e978a07c2bc004c8cc71b96a3170c318 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:52:57 +0000 Subject: [PATCH 169/314] fix(security): harden autoAllowSkills exec matching --- CHANGELOG.md | 1 + src/infra/exec-approvals-allowlist.ts | 21 +++++++++-- src/infra/exec-approvals.test.ts | 53 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f15aaa71f..54c30bc75e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. +- Security/Exec approvals: harden `autoAllowSkills` matching to require pathless invocations with resolved executables, blocking `./`/absolute-path basename collisions from satisfying skill auto-allow checks under allowlist mode. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index ba321b609c7..6d48347e403 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -92,6 +92,10 @@ export function isSafeBinUsage(params: { return validateSafeBinArgv(argv, profile); } +function isPathScopedExecutableToken(token: string): boolean { + return token.includes("/") || token.includes("\\"); +} + export type ExecAllowlistEvaluation = { allowlistSatisfied: boolean; allowlistMatches: ExecAllowlistEntry[]; @@ -147,10 +151,19 @@ function evaluateSegments( platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, }); - const skillAllow = - allowSkills && segment.resolution?.executableName - ? params.skillBins?.has(segment.resolution.executableName) - : false; + const rawExecutable = segment.resolution?.rawExecutable?.trim() ?? ""; + const executableName = segment.resolution?.executableName; + const usesExplicitPath = isPathScopedExecutableToken(rawExecutable); + let skillAllow = false; + if ( + allowSkills && + segment.resolution?.resolvedPath && + rawExecutable.length > 0 && + !usesExplicitPath && + executableName + ) { + skillAllow = Boolean(params.skillBins?.has(executableName)); + } const by: ExecSegmentSatisfiedBy = match ? "allowlist" : safe diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 2e7702714be..cddc8cfbdf6 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -627,6 +627,59 @@ describe("exec approvals allowlist evaluation", () => { }); expect(result.allowlistSatisfied).toBe(true); }); + + it("does not satisfy auto-allow skills for explicit relative paths", () => { + const analysis = { + ok: true, + segments: [ + { + raw: "./skill-bin", + argv: ["./skill-bin", "--help"], + resolution: { + rawExecutable: "./skill-bin", + resolvedPath: "/tmp/skill-bin", + executableName: "skill-bin", + }, + }, + ], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [], + safeBins: new Set(), + skillBins: new Set(["skill-bin"]), + autoAllowSkills: true, + cwd: "/tmp", + }); + expect(result.allowlistSatisfied).toBe(false); + expect(result.segmentSatisfiedBy).toEqual([null]); + }); + + it("does not satisfy auto-allow skills when command resolution is missing", () => { + const analysis = { + ok: true, + segments: [ + { + raw: "skill-bin --help", + argv: ["skill-bin", "--help"], + resolution: { + rawExecutable: "skill-bin", + executableName: "skill-bin", + }, + }, + ], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [], + safeBins: new Set(), + skillBins: new Set(["skill-bin"]), + autoAllowSkills: true, + cwd: "/tmp", + }); + expect(result.allowlistSatisfied).toBe(false); + expect(result.segmentSatisfiedBy).toEqual([null]); + }); }); describe("exec approvals policy helpers", () => { From ef1ffacfb2bc2b3e790ab0aaa4fd3bd6e243a678 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 02:53:30 +0000 Subject: [PATCH 170/314] scripts: exclude unresolved clawtributors from README --- scripts/update-clawtributors.ts | 116 ++++++++++++-------------------- 1 file changed, 44 insertions(+), 72 deletions(-) diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 77724d2b019..0e106e65969 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -15,7 +15,6 @@ const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {}); const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase()); const readmePath = resolve("README.md"); -const placeholderAvatar = mapConfig.placeholderAvatar ?? "assets/avatar-placeholder.svg"; const seedCommit = mapConfig.seedCommit ?? null; const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : []; const raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); @@ -98,33 +97,33 @@ for (const login of ensureLogins) { const entriesByKey = new Map(); for (const seed of seedEntries) { - const login = loginFromUrl(seed.html_url); - const resolvedLogin = - login ?? resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); - const key = resolvedLogin ? resolvedLogin.toLowerCase() : `name:${normalizeName(seed.display)}`; - const avatar = - seed.avatar_url && !isGhostAvatar(seed.avatar_url) - ? normalizeAvatar(seed.avatar_url) - : placeholderAvatar; + const login = + loginFromUrl(seed.html_url) ?? + resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin); + if (!login) { + continue; + } + const key = login.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(login); + if (!user) { + continue; + } + apiByLogin.set(key, user); const existing = entriesByKey.get(key); if (!existing) { - const user = resolvedLogin ? apiByLogin.get(key) : null; entriesByKey.set(key, { key, - login: resolvedLogin ?? login ?? undefined, + login: user.login, display: seed.display, - html_url: user?.html_url ?? seed.html_url, - avatar_url: user?.avatar_url ?? avatar, + html_url: user.html_url, + avatar_url: user.avatar_url, lines: 0, }); } else { existing.display = existing.display || seed.display; - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - existing.avatar_url = avatar; - } - if (!existing.html_url || existing.html_url.includes("/search?q=")) { - existing.html_url = seed.html_url; - } + existing.login = user.login; + existing.html_url = user.html_url; + existing.avatar_url = user.avatar_url; } } @@ -138,52 +137,37 @@ for (const item of contributors) { ? item.login : resolveLogin(baseName, item.email ?? null, apiByLogin, nameToLogin, emailToLogin); - if (resolvedLogin) { - const key = resolvedLogin.toLowerCase(); - const existing = entriesByKey.get(key); - if (!existing) { - let user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - entriesByKey.set(key, { - key, - login: user.login, - display: pickDisplay(baseName, user.login, existing?.display), - html_url: user.html_url, - avatar_url: normalizeAvatar(user.avatar_url), - lines: lines > 0 ? lines : contributions, - }); - } - } else if (existing) { - existing.login = existing.login ?? resolvedLogin; - existing.display = pickDisplay(baseName, existing.login, existing.display); - if (existing.avatar_url === placeholderAvatar || !existing.avatar_url) { - const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); - if (user) { - existing.html_url = user.html_url; - existing.avatar_url = normalizeAvatar(user.avatar_url); - } - } - const lines = linesByLogin.get(key) ?? 0; - const contributions = contributionsByLogin.get(key) ?? 0; - existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); - } + if (!resolvedLogin) { continue; } - const anonKey = `name:${normalizeName(baseName)}`; - const existingAnon = entriesByKey.get(anonKey); - if (!existingAnon) { - entriesByKey.set(anonKey, { - key: anonKey, - display: baseName, - html_url: fallbackHref(baseName), - avatar_url: placeholderAvatar, - lines: item.contributions ?? 0, + const key = resolvedLogin.toLowerCase(); + const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin); + if (!user) { + continue; + } + apiByLogin.set(key, user); + + const existing = entriesByKey.get(key); + if (!existing) { + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + entriesByKey.set(key, { + key, + login: user.login, + display: pickDisplay(baseName, user.login), + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines: lines > 0 ? lines : contributions, }); } else { - existingAnon.lines = Math.max(existingAnon.lines, item.contributions ?? 0); + existing.login = user.login; + existing.display = pickDisplay(baseName, user.login, existing.display); + existing.html_url = user.html_url; + existing.avatar_url = normalizeAvatar(user.avatar_url); + const lines = linesByLogin.get(key) ?? 0; + const contributions = contributionsByLogin.get(key) ?? 0; + existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions); } } @@ -205,14 +189,6 @@ for (const [login, lines] of linesByLogin.entries()) { avatar_url: normalizeAvatar(user.avatar_url), lines: lines > 0 ? lines : contributions, }); - } else { - entriesByKey.set(login, { - key: login, - display: login, - html_url: fallbackHref(login), - avatar_url: placeholderAvatar, - lines, - }); } } @@ -323,10 +299,6 @@ function normalizeAvatar(url: string): string { return `${url}${sep}s=48`; } -function isGhostAvatar(url: string): boolean { - return url.toLowerCase().includes("ghost.png"); -} - function fetchUser(login: string): User | null { const normalized = normalizeLogin(login); if (!normalized) { From 71f4b93656e29700997e9458042eeef04cb405f1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 24 Feb 2026 02:54:19 +0000 Subject: [PATCH 171/314] docs: refresh clawtributors list --- README.md | 124 ++++++++++++++++++++++-------------------------------- 1 file changed, 50 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 72f362418d7..3cc1bacfc3f 100644 --- a/README.md +++ b/README.md @@ -503,78 +503,54 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

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

From ff10fe8b91670044a6bb0cd85deb736a0ec8fb55 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:58:15 +0000 Subject: [PATCH 172/314] fix(security): require /etc/shells for shell env fallback --- src/infra/shell-env.test.ts | 41 ++++++++++++++++++++++++++++++------- src/infra/shell-env.ts | 23 +-------------------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 80eda1da580..ab06202dc20 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -28,6 +28,7 @@ describe("shell env fallback", () => { } function runShellEnvFallbackForShell(shell: string) { + resetShellPathCacheForTests(); const env: NodeJS.ProcessEnv = { SHELL: shell }; const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0")); const res = loadShellEnvFallback({ @@ -170,18 +171,44 @@ describe("shell env fallback", () => { expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); }); - it("uses trusted absolute SHELL path when executable on posix-style paths", () => { - const accessSyncSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined); + it("falls back to /bin/sh when SHELL is absolute but not registered in /etc/shells", () => { + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return "/bin/sh\n/bin/bash\n/bin/zsh\n"; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); try { - const trustedShell = "/usr/bin/zsh-trusted"; - const { res, exec } = runShellEnvFallbackForShell(trustedShell); - const expectedShell = process.platform === "win32" ? "/bin/sh" : trustedShell; + const { res, exec } = runShellEnvFallbackForShell("/opt/homebrew/bin/evil-shell"); expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); - expect(exec).toHaveBeenCalledWith(expectedShell, ["-l", "-c", "env -0"], expect.any(Object)); + expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); } finally { - accessSyncSpy.mockRestore(); + readFileSyncSpy.mockRestore(); + } + }); + + it("uses SHELL when it is explicitly registered in /etc/shells", () => { + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return "/bin/sh\n/usr/bin/zsh-trusted\n"; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); + try { + const trustedShell = "/usr/bin/zsh-trusted"; + const { res, exec } = runShellEnvFallbackForShell(trustedShell); + + expect(res.ok).toBe(true); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith(trustedShell, ["-l", "-c", "env -0"], expect.any(Object)); + } finally { + readFileSyncSpy.mockRestore(); } }); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 30f255cbce6..ac1369c48be 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -8,13 +8,6 @@ import { sanitizeHostExecEnv } from "./host-env-security.js"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const DEFAULT_SHELL = "/bin/sh"; -const TRUSTED_SHELL_PREFIXES = [ - "/bin/", - "/usr/bin/", - "/usr/local/bin/", - "/opt/homebrew/bin/", - "/run/current-system/sw/bin/", -]; let lastAppliedKeys: string[] = []; let cachedShellPath: string | null | undefined; let cachedEtcShells: Set | null | undefined; @@ -70,21 +63,7 @@ function isTrustedShellPath(shell: string): boolean { // Primary trust anchor: shell registered in /etc/shells. const registeredShells = readEtcShells(); - if (registeredShells?.has(shell)) { - return true; - } - - // Fallback for environments where /etc/shells is incomplete/unavailable. - if (!TRUSTED_SHELL_PREFIXES.some((prefix) => shell.startsWith(prefix))) { - return false; - } - - try { - fs.accessSync(shell, fs.constants.X_OK); - return true; - } catch { - return false; - } + return registeredShells?.has(shell) === true; } function resolveShell(env: NodeJS.ProcessEnv): string { From d0ef4c75c7eb19ae562587c9d0a9afb3beec9560 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 02:59:10 +0000 Subject: [PATCH 173/314] docs(changelog): credit safeBins advisory reporters --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c30bc75e3..c25761ec4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Docs: https://docs.openclaw.ai - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. From 60f1d1959aa05eb08348c9b32e091e7b074e5692 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:02:32 +0000 Subject: [PATCH 174/314] test: stabilize invoke-system-run env-wrapper assertion on Windows --- src/node-host/invoke-system-run.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index bffe6c638ba..410382a5aad 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -120,12 +120,25 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { ); }); - it("runs canonical argv in allowlist mode for transparent env wrappers", async () => { + it("handles transparent env wrappers in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, security: "allowlist", command: ["env", "tr", "a", "b"], }); + if (process.platform === "win32") { + expect(runCommand).not.toHaveBeenCalled(); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("allowlist miss"), + }), + }), + ); + return; + } + expect(runCommand).toHaveBeenCalledWith(["tr", "a", "b"], undefined, undefined, undefined); expect(sendInvokeResult).toHaveBeenCalledWith( expect.objectContaining({ From c5ac90ab92fbb9949acb8335f33e672f94646198 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:04:43 +0000 Subject: [PATCH 175/314] docs(changelog): add shell-env fallback hardening note --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c25761ec4c8..3a712c74de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. From 9530c0108589b4a956ebf0338d7ae69648fd1d8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:04:57 +0000 Subject: [PATCH 176/314] refactor(exec): split safe-bin policy modules and dedupe allowlist flow --- src/infra/exec-approvals-allowlist.ts | 65 ++- src/infra/exec-safe-bin-policy-profiles.ts | 315 +++++++++++++ src/infra/exec-safe-bin-policy-validator.ts | 206 +++++++++ src/infra/exec-safe-bin-policy.ts | 485 +------------------- 4 files changed, 565 insertions(+), 506 deletions(-) create mode 100644 src/infra/exec-safe-bin-policy-profiles.ts create mode 100644 src/infra/exec-safe-bin-policy-validator.ts diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 6d48347e403..bff632d46be 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -178,6 +178,13 @@ function evaluateSegments( return { satisfied, matches, segmentSatisfiedBy }; } +function resolveAnalysisSegmentGroups(analysis: ExecCommandAnalysis): ExecCommandSegment[][] { + if (analysis.chains) { + return analysis.chains; + } + return [analysis.segments]; +} + export function evaluateExecAllowlist(params: { analysis: ExecCommandAnalysis; allowlist: ExecAllowlistEntry[]; @@ -195,44 +202,32 @@ export function evaluateExecAllowlist(params: { return { allowlistSatisfied: false, allowlistMatches, segmentSatisfiedBy }; } - // If the analysis contains chains, evaluate each chain part separately - if (params.analysis.chains) { - for (const chainSegments of params.analysis.chains) { - const result = evaluateSegments(chainSegments, { - allowlist: params.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - platform: params.platform, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - if (!result.satisfied) { - return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] }; + const hasChains = Boolean(params.analysis.chains); + for (const group of resolveAnalysisSegmentGroups(params.analysis)) { + const result = evaluateSegments(group, { + allowlist: params.allowlist, + safeBins: params.safeBins, + safeBinProfiles: params.safeBinProfiles, + cwd: params.cwd, + platform: params.platform, + trustedSafeBinDirs: params.trustedSafeBinDirs, + skillBins: params.skillBins, + autoAllowSkills: params.autoAllowSkills, + }); + if (!result.satisfied) { + if (!hasChains) { + return { + allowlistSatisfied: false, + allowlistMatches: result.matches, + segmentSatisfiedBy: result.segmentSatisfiedBy, + }; } - allowlistMatches.push(...result.matches); - segmentSatisfiedBy.push(...result.segmentSatisfiedBy); + return { allowlistSatisfied: false, allowlistMatches: [], segmentSatisfiedBy: [] }; } - return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy }; + allowlistMatches.push(...result.matches); + segmentSatisfiedBy.push(...result.segmentSatisfiedBy); } - - // No chains, evaluate all segments together - const result = evaluateSegments(params.analysis.segments, { - allowlist: params.allowlist, - safeBins: params.safeBins, - safeBinProfiles: params.safeBinProfiles, - cwd: params.cwd, - platform: params.platform, - trustedSafeBinDirs: params.trustedSafeBinDirs, - skillBins: params.skillBins, - autoAllowSkills: params.autoAllowSkills, - }); - return { - allowlistSatisfied: result.satisfied, - allowlistMatches: result.matches, - segmentSatisfiedBy: result.segmentSatisfiedBy, - }; + return { allowlistSatisfied: true, allowlistMatches, segmentSatisfiedBy }; } export type ExecAllowlistAnalysis = { diff --git a/src/infra/exec-safe-bin-policy-profiles.ts b/src/infra/exec-safe-bin-policy-profiles.ts new file mode 100644 index 00000000000..b450325d2fe --- /dev/null +++ b/src/infra/exec-safe-bin-policy-profiles.ts @@ -0,0 +1,315 @@ +export type SafeBinProfile = { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: ReadonlySet; + deniedFlags?: ReadonlySet; + // Precomputed long-option metadata for GNU abbreviation resolution. + knownLongFlags?: readonly string[]; + knownLongFlagsSet?: ReadonlySet; + longFlagPrefixMap?: ReadonlyMap; +}; + +export type SafeBinProfileFixture = { + minPositional?: number; + maxPositional?: number; + allowedValueFlags?: readonly string[]; + deniedFlags?: readonly string[]; +}; + +export type SafeBinProfileFixtures = Readonly>; + +const NO_FLAGS: ReadonlySet = new Set(); + +const toFlagSet = (flags?: readonly string[]): ReadonlySet => { + if (!flags || flags.length === 0) { + return NO_FLAGS; + } + return new Set(flags); +}; + +export function collectKnownLongFlags( + allowedValueFlags: ReadonlySet, + deniedFlags: ReadonlySet, +): string[] { + const known = new Set(); + for (const flag of allowedValueFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + for (const flag of deniedFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + return Array.from(known); +} + +export function buildLongFlagPrefixMap( + knownLongFlags: readonly string[], +): ReadonlyMap { + const prefixMap = new Map(); + for (const flag of knownLongFlags) { + if (!flag.startsWith("--") || flag.length <= 2) { + continue; + } + for (let length = 3; length <= flag.length; length += 1) { + const prefix = flag.slice(0, length); + const existing = prefixMap.get(prefix); + if (existing === undefined) { + prefixMap.set(prefix, flag); + continue; + } + if (existing !== flag) { + prefixMap.set(prefix, null); + } + } + } + return prefixMap; +} + +function compileSafeBinProfile(fixture: SafeBinProfileFixture): SafeBinProfile { + const allowedValueFlags = toFlagSet(fixture.allowedValueFlags); + const deniedFlags = toFlagSet(fixture.deniedFlags); + const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); + return { + minPositional: fixture.minPositional, + maxPositional: fixture.maxPositional, + allowedValueFlags, + deniedFlags, + knownLongFlags, + knownLongFlagsSet: new Set(knownLongFlags), + longFlagPrefixMap: buildLongFlagPrefixMap(knownLongFlags), + }; +} + +function compileSafeBinProfiles( + fixtures: Record, +): Record { + return Object.fromEntries( + Object.entries(fixtures).map(([name, fixture]) => [name, compileSafeBinProfile(fixture)]), + ) as Record; +} + +export const SAFE_BIN_PROFILE_FIXTURES: Record = { + jq: { + maxPositional: 1, + allowedValueFlags: ["--arg", "--argjson", "--argstr"], + deniedFlags: [ + "--argfile", + "--rawfile", + "--slurpfile", + "--from-file", + "--library-path", + "-L", + "-f", + ], + }, + grep: { + // Keep grep stdin-only: pattern must come from -e/--regexp. + // Allowing one positional is ambiguous because -e consumes the pattern and + // frees the positional slot for a filename. + maxPositional: 0, + allowedValueFlags: [ + "--regexp", + "--max-count", + "--after-context", + "--before-context", + "--context", + "--devices", + "--binary-files", + "--exclude", + "--include", + "--label", + "-e", + "-m", + "-A", + "-B", + "-C", + "-D", + ], + deniedFlags: [ + "--file", + "--exclude-from", + "--dereference-recursive", + "--directories", + "--recursive", + "-f", + "-d", + "-r", + "-R", + ], + }, + cut: { + maxPositional: 0, + allowedValueFlags: [ + "--bytes", + "--characters", + "--fields", + "--delimiter", + "--output-delimiter", + "-b", + "-c", + "-f", + "-d", + ], + }, + sort: { + maxPositional: 0, + allowedValueFlags: [ + "--key", + "--field-separator", + "--buffer-size", + "--parallel", + "--batch-size", + "-k", + "-t", + "-S", + ], + // --compress-program can invoke an external executable and breaks stdin-only guarantees. + // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. + deniedFlags: [ + "--compress-program", + "--files0-from", + "--output", + "--random-source", + "--temporary-directory", + "-T", + "-o", + ], + }, + uniq: { + maxPositional: 0, + allowedValueFlags: [ + "--skip-fields", + "--skip-chars", + "--check-chars", + "--group", + "-f", + "-s", + "-w", + ], + }, + head: { + maxPositional: 0, + allowedValueFlags: ["--lines", "--bytes", "-n", "-c"], + }, + tail: { + maxPositional: 0, + allowedValueFlags: [ + "--lines", + "--bytes", + "--sleep-interval", + "--max-unchanged-stats", + "--pid", + "-n", + "-c", + ], + }, + tr: { + minPositional: 1, + maxPositional: 2, + }, + wc: { + maxPositional: 0, + deniedFlags: ["--files0-from"], + }, +}; + +export const SAFE_BIN_PROFILES: Record = + compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); + +function normalizeSafeBinProfileName(raw: string): string | null { + const name = raw.trim().toLowerCase(); + return name.length > 0 ? name : null; +} + +function normalizeFixtureLimit(raw: number | undefined): number | undefined { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + const next = Math.trunc(raw); + return next >= 0 ? next : undefined; +} + +function normalizeFixtureFlags( + flags: readonly string[] | undefined, +): readonly string[] | undefined { + if (!Array.isArray(flags) || flags.length === 0) { + return undefined; + } + const normalized = Array.from( + new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), + ).toSorted((a, b) => a.localeCompare(b)); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { + const minPositional = normalizeFixtureLimit(fixture.minPositional); + const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); + const maxPositional = + minPositional !== undefined && + maxPositionalRaw !== undefined && + maxPositionalRaw < minPositional + ? minPositional + : maxPositionalRaw; + return { + minPositional, + maxPositional, + allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), + deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), + }; +} + +export function normalizeSafeBinProfileFixtures( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalized: Record = {}; + if (!fixtures) { + return normalized; + } + for (const [rawName, fixture] of Object.entries(fixtures)) { + const name = normalizeSafeBinProfileName(rawName); + if (!name) { + continue; + } + normalized[name] = normalizeSafeBinProfileFixture(fixture); + } + return normalized; +} + +export function resolveSafeBinProfiles( + fixtures?: SafeBinProfileFixtures | null, +): Record { + const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); + if (Object.keys(normalizedFixtures).length === 0) { + return SAFE_BIN_PROFILES; + } + return { + ...SAFE_BIN_PROFILES, + ...compileSafeBinProfiles(normalizedFixtures), + }; +} + +export function resolveSafeBinDeniedFlags( + fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, +): Record { + const out: Record = {}; + for (const [name, fixture] of Object.entries(fixtures)) { + const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); + if (denied.length > 0) { + out[name] = denied; + } + } + return out; +} + +export function renderSafeBinDeniedFlagsDocBullets( + fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, +): string { + const deniedByBin = resolveSafeBinDeniedFlags(fixtures); + const bins = Object.keys(deniedByBin).toSorted(); + return bins + .map((bin) => `- \`${bin}\`: ${deniedByBin[bin].map((flag) => `\`${flag}\``).join(", ")}`) + .join("\n"); +} diff --git a/src/infra/exec-safe-bin-policy-validator.ts b/src/infra/exec-safe-bin-policy-validator.ts new file mode 100644 index 00000000000..83160285242 --- /dev/null +++ b/src/infra/exec-safe-bin-policy-validator.ts @@ -0,0 +1,206 @@ +import { parseExecArgvToken } from "./exec-approvals-analysis.js"; +import { + buildLongFlagPrefixMap, + collectKnownLongFlags, + type SafeBinProfile, +} from "./exec-safe-bin-policy-profiles.js"; + +function isPathLikeToken(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (trimmed === "-") { + return false; + } + if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) { + return true; + } + if (trimmed.startsWith("/")) { + return true; + } + return /^[A-Za-z]:[\\/]/.test(trimmed); +} + +function hasGlobToken(value: string): boolean { + // Safe bins are stdin-only; globbing is both surprising and a historical bypass vector. + // Note: we still harden execution-time expansion separately. + return /[*?[\]]/.test(value); +} + +const NO_FLAGS: ReadonlySet = new Set(); + +function isSafeLiteralToken(value: string): boolean { + if (!value || value === "-") { + return true; + } + return !hasGlobToken(value) && !isPathLikeToken(value); +} + +function isInvalidValueToken(value: string | undefined): boolean { + return !value || !isSafeLiteralToken(value); +} + +function resolveCanonicalLongFlag(params: { + flag: string; + knownLongFlagsSet: ReadonlySet; + longFlagPrefixMap: ReadonlyMap; +}): string | null { + if (!params.flag.startsWith("--") || params.flag.length <= 2) { + return null; + } + if (params.knownLongFlagsSet.has(params.flag)) { + return params.flag; + } + return params.longFlagPrefixMap.get(params.flag) ?? null; +} + +function consumeLongOptionToken(params: { + args: string[]; + index: number; + flag: string; + inlineValue: string | undefined; + allowedValueFlags: ReadonlySet; + deniedFlags: ReadonlySet; + knownLongFlagsSet: ReadonlySet; + longFlagPrefixMap: ReadonlyMap; +}): number { + const canonicalFlag = resolveCanonicalLongFlag({ + flag: params.flag, + knownLongFlagsSet: params.knownLongFlagsSet, + longFlagPrefixMap: params.longFlagPrefixMap, + }); + if (!canonicalFlag) { + return -1; + } + if (params.deniedFlags.has(canonicalFlag)) { + return -1; + } + const expectsValue = params.allowedValueFlags.has(canonicalFlag); + if (params.inlineValue !== undefined) { + if (!expectsValue) { + return -1; + } + return isSafeLiteralToken(params.inlineValue) ? params.index + 1 : -1; + } + if (!expectsValue) { + return params.index + 1; + } + return isInvalidValueToken(params.args[params.index + 1]) ? -1 : params.index + 2; +} + +function consumeShortOptionClusterToken(params: { + args: string[]; + index: number; + cluster: string; + flags: string[]; + allowedValueFlags: ReadonlySet; + deniedFlags: ReadonlySet; +}): number { + for (let j = 0; j < params.flags.length; j += 1) { + const flag = params.flags[j]; + if (params.deniedFlags.has(flag)) { + return -1; + } + if (!params.allowedValueFlags.has(flag)) { + continue; + } + const inlineValue = params.cluster.slice(j + 1); + if (inlineValue) { + return isSafeLiteralToken(inlineValue) ? params.index + 1 : -1; + } + return isInvalidValueToken(params.args[params.index + 1]) ? -1 : params.index + 2; + } + return -1; +} + +function consumePositionalToken(token: string, positional: string[]): boolean { + if (!isSafeLiteralToken(token)) { + return false; + } + positional.push(token); + return true; +} + +function validatePositionalCount(positional: string[], profile: SafeBinProfile): boolean { + const minPositional = profile.minPositional ?? 0; + if (positional.length < minPositional) { + return false; + } + return typeof profile.maxPositional !== "number" || positional.length <= profile.maxPositional; +} + +export function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { + const allowedValueFlags = profile.allowedValueFlags ?? NO_FLAGS; + const deniedFlags = profile.deniedFlags ?? NO_FLAGS; + const knownLongFlags = + profile.knownLongFlags ?? collectKnownLongFlags(allowedValueFlags, deniedFlags); + const knownLongFlagsSet = profile.knownLongFlagsSet ?? new Set(knownLongFlags); + const longFlagPrefixMap = profile.longFlagPrefixMap ?? buildLongFlagPrefixMap(knownLongFlags); + + const positional: string[] = []; + let i = 0; + while (i < args.length) { + const rawToken = args[i] ?? ""; + const token = parseExecArgvToken(rawToken); + + if (token.kind === "empty" || token.kind === "stdin") { + i += 1; + continue; + } + + if (token.kind === "terminator") { + for (let j = i + 1; j < args.length; j += 1) { + const rest = args[j]; + if (!rest || rest === "-") { + continue; + } + if (!consumePositionalToken(rest, positional)) { + return false; + } + } + break; + } + + if (token.kind === "positional") { + if (!consumePositionalToken(token.raw, positional)) { + return false; + } + i += 1; + continue; + } + + if (token.style === "long") { + const nextIndex = consumeLongOptionToken({ + args, + index: i, + flag: token.flag, + inlineValue: token.inlineValue, + allowedValueFlags, + deniedFlags, + knownLongFlagsSet, + longFlagPrefixMap, + }); + if (nextIndex < 0) { + return false; + } + i = nextIndex; + continue; + } + + const nextIndex = consumeShortOptionClusterToken({ + args, + index: i, + cluster: token.cluster, + flags: token.flags, + allowedValueFlags, + deniedFlags, + }); + if (nextIndex < 0) { + return false; + } + i = nextIndex; + } + + return validatePositionalCount(positional, profile); +} diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index d726bb55a10..cd859809828 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -1,472 +1,15 @@ -import { parseExecArgvToken } from "./exec-approvals-analysis.js"; +export { + SAFE_BIN_PROFILE_FIXTURES, + SAFE_BIN_PROFILES, + buildLongFlagPrefixMap, + collectKnownLongFlags, + normalizeSafeBinProfileFixtures, + renderSafeBinDeniedFlagsDocBullets, + resolveSafeBinDeniedFlags, + resolveSafeBinProfiles, + type SafeBinProfile, + type SafeBinProfileFixture, + type SafeBinProfileFixtures, +} from "./exec-safe-bin-policy-profiles.js"; -function isPathLikeToken(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - if (trimmed === "-") { - return false; - } - if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) { - return true; - } - if (trimmed.startsWith("/")) { - return true; - } - return /^[A-Za-z]:[\\/]/.test(trimmed); -} - -function hasGlobToken(value: string): boolean { - // Safe bins are stdin-only; globbing is both surprising and a historical bypass vector. - // Note: we still harden execution-time expansion separately. - return /[*?[\]]/.test(value); -} - -export type SafeBinProfile = { - minPositional?: number; - maxPositional?: number; - allowedValueFlags?: ReadonlySet; - deniedFlags?: ReadonlySet; -}; - -export type SafeBinProfileFixture = { - minPositional?: number; - maxPositional?: number; - allowedValueFlags?: readonly string[]; - deniedFlags?: readonly string[]; -}; - -export type SafeBinProfileFixtures = Readonly>; - -const NO_FLAGS: ReadonlySet = new Set(); - -const toFlagSet = (flags?: readonly string[]): ReadonlySet => { - if (!flags || flags.length === 0) { - return NO_FLAGS; - } - return new Set(flags); -}; - -function compileSafeBinProfile(fixture: SafeBinProfileFixture): SafeBinProfile { - return { - minPositional: fixture.minPositional, - maxPositional: fixture.maxPositional, - allowedValueFlags: toFlagSet(fixture.allowedValueFlags), - deniedFlags: toFlagSet(fixture.deniedFlags), - }; -} - -function compileSafeBinProfiles( - fixtures: Record, -): Record { - return Object.fromEntries( - Object.entries(fixtures).map(([name, fixture]) => [name, compileSafeBinProfile(fixture)]), - ) as Record; -} - -export const SAFE_BIN_PROFILE_FIXTURES: Record = { - jq: { - maxPositional: 1, - allowedValueFlags: ["--arg", "--argjson", "--argstr"], - deniedFlags: [ - "--argfile", - "--rawfile", - "--slurpfile", - "--from-file", - "--library-path", - "-L", - "-f", - ], - }, - grep: { - // Keep grep stdin-only: pattern must come from -e/--regexp. - // Allowing one positional is ambiguous because -e consumes the pattern and - // frees the positional slot for a filename. - maxPositional: 0, - allowedValueFlags: [ - "--regexp", - "--max-count", - "--after-context", - "--before-context", - "--context", - "--devices", - "--binary-files", - "--exclude", - "--include", - "--label", - "-e", - "-m", - "-A", - "-B", - "-C", - "-D", - ], - deniedFlags: [ - "--file", - "--exclude-from", - "--dereference-recursive", - "--directories", - "--recursive", - "-f", - "-d", - "-r", - "-R", - ], - }, - cut: { - maxPositional: 0, - allowedValueFlags: [ - "--bytes", - "--characters", - "--fields", - "--delimiter", - "--output-delimiter", - "-b", - "-c", - "-f", - "-d", - ], - }, - sort: { - maxPositional: 0, - allowedValueFlags: [ - "--key", - "--field-separator", - "--buffer-size", - "--parallel", - "--batch-size", - "-k", - "-t", - "-S", - ], - // --compress-program can invoke an external executable and breaks stdin-only guarantees. - // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. - deniedFlags: [ - "--compress-program", - "--files0-from", - "--output", - "--random-source", - "--temporary-directory", - "-T", - "-o", - ], - }, - uniq: { - maxPositional: 0, - allowedValueFlags: [ - "--skip-fields", - "--skip-chars", - "--check-chars", - "--group", - "-f", - "-s", - "-w", - ], - }, - head: { - maxPositional: 0, - allowedValueFlags: ["--lines", "--bytes", "-n", "-c"], - }, - tail: { - maxPositional: 0, - allowedValueFlags: [ - "--lines", - "--bytes", - "--sleep-interval", - "--max-unchanged-stats", - "--pid", - "-n", - "-c", - ], - }, - tr: { - minPositional: 1, - maxPositional: 2, - }, - wc: { - maxPositional: 0, - deniedFlags: ["--files0-from"], - }, -}; - -export const SAFE_BIN_PROFILES: Record = - compileSafeBinProfiles(SAFE_BIN_PROFILE_FIXTURES); - -function normalizeSafeBinProfileName(raw: string): string | null { - const name = raw.trim().toLowerCase(); - return name.length > 0 ? name : null; -} - -function normalizeFixtureLimit(raw: number | undefined): number | undefined { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return undefined; - } - const next = Math.trunc(raw); - return next >= 0 ? next : undefined; -} - -function normalizeFixtureFlags( - flags: readonly string[] | undefined, -): readonly string[] | undefined { - if (!Array.isArray(flags) || flags.length === 0) { - return undefined; - } - const normalized = Array.from( - new Set(flags.map((flag) => flag.trim()).filter((flag) => flag.length > 0)), - ).toSorted((a, b) => a.localeCompare(b)); - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeSafeBinProfileFixture(fixture: SafeBinProfileFixture): SafeBinProfileFixture { - const minPositional = normalizeFixtureLimit(fixture.minPositional); - const maxPositionalRaw = normalizeFixtureLimit(fixture.maxPositional); - const maxPositional = - minPositional !== undefined && - maxPositionalRaw !== undefined && - maxPositionalRaw < minPositional - ? minPositional - : maxPositionalRaw; - return { - minPositional, - maxPositional, - allowedValueFlags: normalizeFixtureFlags(fixture.allowedValueFlags), - deniedFlags: normalizeFixtureFlags(fixture.deniedFlags), - }; -} - -export function normalizeSafeBinProfileFixtures( - fixtures?: SafeBinProfileFixtures | null, -): Record { - const normalized: Record = {}; - if (!fixtures) { - return normalized; - } - for (const [rawName, fixture] of Object.entries(fixtures)) { - const name = normalizeSafeBinProfileName(rawName); - if (!name) { - continue; - } - normalized[name] = normalizeSafeBinProfileFixture(fixture); - } - return normalized; -} - -export function resolveSafeBinProfiles( - fixtures?: SafeBinProfileFixtures | null, -): Record { - const normalizedFixtures = normalizeSafeBinProfileFixtures(fixtures); - if (Object.keys(normalizedFixtures).length === 0) { - return SAFE_BIN_PROFILES; - } - return { - ...SAFE_BIN_PROFILES, - ...compileSafeBinProfiles(normalizedFixtures), - }; -} - -export function resolveSafeBinDeniedFlags( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): Record { - const out: Record = {}; - for (const [name, fixture] of Object.entries(fixtures)) { - const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); - if (denied.length > 0) { - out[name] = denied; - } - } - return out; -} - -export function renderSafeBinDeniedFlagsDocBullets( - fixtures: Readonly> = SAFE_BIN_PROFILE_FIXTURES, -): string { - const deniedByBin = resolveSafeBinDeniedFlags(fixtures); - const bins = Object.keys(deniedByBin).toSorted(); - return bins - .map((bin) => `- \`${bin}\`: ${deniedByBin[bin].map((flag) => `\`${flag}\``).join(", ")}`) - .join("\n"); -} - -function isSafeLiteralToken(value: string): boolean { - if (!value || value === "-") { - return true; - } - return !hasGlobToken(value) && !isPathLikeToken(value); -} - -function isInvalidValueToken(value: string | undefined): boolean { - return !value || !isSafeLiteralToken(value); -} - -function collectKnownLongFlags( - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): string[] { - const known = new Set(); - for (const flag of allowedValueFlags) { - if (flag.startsWith("--")) { - known.add(flag); - } - } - for (const flag of deniedFlags) { - if (flag.startsWith("--")) { - known.add(flag); - } - } - return Array.from(known); -} - -function resolveCanonicalLongFlag(flag: string, knownLongFlags: string[]): string | null { - if (!flag.startsWith("--") || flag.length <= 2) { - return null; - } - if (knownLongFlags.includes(flag)) { - return flag; - } - const matches = knownLongFlags.filter((candidate) => candidate.startsWith(flag)); - if (matches.length !== 1) { - return null; - } - return matches[0] ?? null; -} - -function consumeLongOptionToken( - args: string[], - index: number, - flag: string, - inlineValue: string | undefined, - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): number { - const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); - const canonicalFlag = resolveCanonicalLongFlag(flag, knownLongFlags); - if (!canonicalFlag) { - return -1; - } - if (deniedFlags.has(canonicalFlag)) { - return -1; - } - const expectsValue = allowedValueFlags.has(canonicalFlag); - if (inlineValue !== undefined) { - if (!expectsValue) { - return -1; - } - return isSafeLiteralToken(inlineValue) ? index + 1 : -1; - } - if (!expectsValue) { - return index + 1; - } - return isInvalidValueToken(args[index + 1]) ? -1 : index + 2; -} - -function consumeShortOptionClusterToken( - args: string[], - index: number, - _raw: string, - cluster: string, - flags: string[], - allowedValueFlags: ReadonlySet, - deniedFlags: ReadonlySet, -): number { - for (let j = 0; j < flags.length; j += 1) { - const flag = flags[j]; - if (deniedFlags.has(flag)) { - return -1; - } - if (!allowedValueFlags.has(flag)) { - continue; - } - const inlineValue = cluster.slice(j + 1); - if (inlineValue) { - return isSafeLiteralToken(inlineValue) ? index + 1 : -1; - } - return isInvalidValueToken(args[index + 1]) ? -1 : index + 2; - } - return -1; -} - -function consumePositionalToken(token: string, positional: string[]): boolean { - if (!isSafeLiteralToken(token)) { - return false; - } - positional.push(token); - return true; -} - -function validatePositionalCount(positional: string[], profile: SafeBinProfile): boolean { - const minPositional = profile.minPositional ?? 0; - if (positional.length < minPositional) { - return false; - } - return typeof profile.maxPositional !== "number" || positional.length <= profile.maxPositional; -} - -export function validateSafeBinArgv(args: string[], profile: SafeBinProfile): boolean { - const allowedValueFlags = profile.allowedValueFlags ?? NO_FLAGS; - const deniedFlags = profile.deniedFlags ?? NO_FLAGS; - const positional: string[] = []; - let i = 0; - while (i < args.length) { - const rawToken = args[i] ?? ""; - const token = parseExecArgvToken(rawToken); - - if (token.kind === "empty" || token.kind === "stdin") { - i += 1; - continue; - } - - if (token.kind === "terminator") { - for (let j = i + 1; j < args.length; j += 1) { - const rest = args[j]; - if (!rest || rest === "-") { - continue; - } - if (!consumePositionalToken(rest, positional)) { - return false; - } - } - break; - } - - if (token.kind === "positional") { - if (!consumePositionalToken(token.raw, positional)) { - return false; - } - i += 1; - continue; - } - - if (token.style === "long") { - const nextIndex = consumeLongOptionToken( - args, - i, - token.flag, - token.inlineValue, - allowedValueFlags, - deniedFlags, - ); - if (nextIndex < 0) { - return false; - } - i = nextIndex; - continue; - } - - const nextIndex = consumeShortOptionClusterToken( - args, - i, - token.raw, - token.cluster, - token.flags, - allowedValueFlags, - deniedFlags, - ); - if (nextIndex < 0) { - return false; - } - i = nextIndex; - } - - return validatePositionalCount(positional, profile); -} +export { validateSafeBinArgv } from "./exec-safe-bin-policy-validator.js"; From 4a3f8438e527ac371a67fe7ac68a287f0dbe6063 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:05:36 +0000 Subject: [PATCH 177/314] fix(gateway): bind node exec approvals to nodeId --- CHANGELOG.md | 1 + .../bash-tools.exec-approval-request.test.ts | 3 + .../bash-tools.exec-approval-request.ts | 4 + src/agents/bash-tools.exec-host-node.ts | 1 + src/agents/openclaw-tools.camera.test.ts | 1 + src/agents/tools/nodes-tool.ts | 1 + src/cli/nodes-cli/register.invoke.ts | 1 + src/gateway/exec-approval-manager.ts | 1 + src/gateway/node-invoke-sanitize.ts | 2 + .../node-invoke-system-run-approval.test.ts | 49 +++++++++ .../node-invoke-system-run-approval.ts | 25 +++++ src/gateway/protocol/schema/exec-approvals.ts | 1 + src/gateway/server-methods/exec-approval.ts | 14 ++- src/gateway/server-methods/nodes.ts | 1 + .../server-methods/server-methods.test.ts | 24 ++++ ...server.node-invoke-approval-bypass.test.ts | 103 +++++++++++++++++- src/infra/exec-approval-forwarder.ts | 3 + src/infra/exec-approvals.ts | 1 + 18 files changed, 231 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a712c74de6..03b2e4ecaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 35f5e040869..a0722002c64 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -44,6 +44,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id", command: "echo hi", cwd: "/tmp", + nodeId: undefined, host: "gateway", security: "allowlist", ask: "always", @@ -62,6 +63,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id", command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", security: "allowlist", ask: "on-miss", @@ -74,6 +76,7 @@ describe("requestExecApprovalDecision", () => { id: "approval-id-2", command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", security: "allowlist", ask: "on-miss", diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 2b08495a400..7f0b59736d5 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -9,6 +9,7 @@ export type RequestExecApprovalDecisionParams = { id: string; command: string; cwd: string; + nodeId?: string; host: "gateway" | "node"; security: ExecSecurity; ask: ExecAsk; @@ -27,6 +28,7 @@ export async function requestExecApprovalDecision( id: params.id, command: params.command, cwd: params.cwd, + nodeId: params.nodeId, host: params.host, security: params.security, ask: params.ask, @@ -48,6 +50,7 @@ export async function requestExecApprovalDecisionForHost(params: { command: string; workdir: string; host: "gateway" | "node"; + nodeId?: string; security: ExecSecurity; ask: ExecAsk; agentId?: string; @@ -58,6 +61,7 @@ export async function requestExecApprovalDecisionForHost(params: { id: params.approvalId, command: params.command, cwd: params.workdir, + nodeId: params.nodeId, host: params.host, security: params.security, ask: params.ask, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index 9a663c2a088..fc6893b93bf 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -193,6 +193,7 @@ export async function executeNodeHostCommand( command: params.command, workdir: params.workdir, host: "node", + nodeId, security: hostSecurity, ask: hostAsk, agentId: params.agentId, diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index fb927d33888..3082c849609 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -165,6 +165,7 @@ describe("nodes run", () => { expect(params).toMatchObject({ id: expect.any(String), command: "echo hi", + nodeId: NODE_ID, host: "node", timeoutMs: 120_000, }); diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 3188d7dc1b8..c17ff9f9c48 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -482,6 +482,7 @@ export function createNodesTool(options?: { id: approvalId, command: cmdText, cwd, + nodeId, host: "node", agentId, sessionKey, diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 2a7ec004f84..a53cc783041 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -253,6 +253,7 @@ export function registerNodesInvokeCommands(nodes: Command) { id: approvalId, command: rawCommand ?? argv.join(" "), cwd: opts.cwd, + nodeId, host: "node", security: hostSecurity, ask: hostAsk, diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index 8a2828da970..a065be1916a 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -7,6 +7,7 @@ const RESOLVED_ENTRY_GRACE_MS = 15_000; export type ExecApprovalRequestPayload = { command: string; cwd?: string | null; + nodeId?: string | null; host?: string | null; security?: string | null; ask?: string | null; diff --git a/src/gateway/node-invoke-sanitize.ts b/src/gateway/node-invoke-sanitize.ts index c794405ddea..651399dce08 100644 --- a/src/gateway/node-invoke-sanitize.ts +++ b/src/gateway/node-invoke-sanitize.ts @@ -3,6 +3,7 @@ import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-a import type { GatewayClient } from "./server-methods/types.js"; export function sanitizeNodeInvokeParamsForForwarding(opts: { + nodeId: string; command: string; rawParams: unknown; client: GatewayClient | null; @@ -12,6 +13,7 @@ export function sanitizeNodeInvokeParamsForForwarding(opts: { | { ok: false; message: string; details?: Record } { if (opts.command === "system.run") { return sanitizeSystemRunParamsForForwarding({ + nodeId: opts.nodeId, rawParams: opts.rawParams, client: opts.client, execApprovalManager: opts.execApprovalManager, diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index a5a7c3d9f0d..ddae856048b 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -18,6 +18,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { id: "approval-1", request: { host: "node", + nodeId: "node-1", command, cwd: null, agentId: null, @@ -61,6 +62,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo")), nowMs: now, @@ -82,6 +84,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo SAFE&&whoami")), nowMs: now, @@ -97,6 +100,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager(makeRecord("echo SAFE")), nowMs: now, @@ -117,6 +121,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { approved: true, approvalDecision: "allow-once", }, + nodeId: "node-1", client, execApprovalManager: manager( makeRecord('/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo SAFE"'), @@ -125,4 +130,48 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }); expectAllowOnceForwardingResult(result); }); + + test("rejects approval ids that do not bind a nodeId", () => { + const record = makeRecord("echo SAFE"); + record.request.nodeId = null; + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-1", + client, + execApprovalManager: manager(record), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("missing node binding"); + expect(result.details?.code).toBe("APPROVAL_NODE_BINDING_MISSING"); + }); + + test("rejects approval ids replayed against a different nodeId", () => { + const result = sanitizeSystemRunParamsForForwarding({ + rawParams: { + command: ["echo", "SAFE"], + runId: "approval-1", + approved: true, + approvalDecision: "allow-once", + }, + nodeId: "node-2", + client, + execApprovalManager: manager(makeRecord("echo SAFE")), + nowMs: now, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.message).toContain("not valid for this node"); + expect(result.details?.code).toBe("APPROVAL_NODE_MISMATCH"); + }); }); diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 5684f4221f5..5bf31db8fb5 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -114,6 +114,7 @@ function pickSystemRunParams(raw: Record): Record 0 ? p.id.trim() : null; + const host = typeof p.host === "string" ? p.host.trim() : ""; + const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : ""; + if (host === "node" && !nodeId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "nodeId is required for host=node"), + ); + return; + } if (explicitId && manager.getSnapshot(explicitId)) { respond( false, @@ -68,7 +79,8 @@ export function createExecApprovalHandlers( const request = { command: p.command, cwd: p.cwd ?? null, - host: p.host ?? null, + nodeId: host === "node" ? nodeId : null, + host: host || null, security: p.security ?? null, ask: p.ask ?? null, agentId: p.agentId ?? null, diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 4f076abd59c..f0221033155 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -698,6 +698,7 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } const forwardedParams = sanitizeNodeInvokeParamsForForwarding({ + nodeId, command, rawParams: p.params, client, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 60349d9c0e4..b19a6d8c608 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -248,6 +248,7 @@ describe("exec approval handlers", () => { const defaultExecApprovalRequestParams = { command: "echo ok", cwd: "/tmp", + nodeId: "node-1", host: "node", timeoutMs: 2000, } as const; @@ -323,6 +324,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", }; expect(validateExecApprovalRequestParams(params)).toBe(true); @@ -332,6 +334,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: "/usr/bin/echo", }; @@ -342,6 +345,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: undefined, }; @@ -352,6 +356,7 @@ describe("exec approval handlers", () => { const params = { command: "echo hi", cwd: "/tmp", + nodeId: "node-1", host: "node", resolvedPath: null, }; @@ -359,6 +364,25 @@ describe("exec approval handlers", () => { }); }); + it("rejects host=node approval requests without nodeId", async () => { + const { handlers, respond, context } = createExecApprovalFixture(); + await requestExecApproval({ + handlers, + respond, + context, + params: { + nodeId: undefined, + }, + }); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: "nodeId is required for host=node", + }), + ); + }); + it("broadcasts request + resolve", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 9a78453a199..7cc84b5b8d8 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { deriveDeviceIdFromPublicKey, + type DeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js"; @@ -23,6 +24,22 @@ installGatewayTestHooks({ scope: "suite" }); const NODE_CONNECT_TIMEOUT_MS = 3_000; const CONNECT_REQ_TIMEOUT_MS = 2_000; +function createDeviceIdentity(): DeviceIdentity { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem); + const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw); + if (!deviceId) { + throw new Error("failed to create test device identity"); + } + return { + deviceId, + publicKeyPem, + privateKeyPem, + }; +} + async function expectNoForwardedInvoke(hasInvoke: () => boolean): Promise { // Yield a couple of macrotasks so any accidental async forwarding would fire. await new Promise((resolve) => setImmediate(resolve)); @@ -42,11 +59,26 @@ async function getConnectedNodeId(ws: WebSocket): Promise { return nodeId; } -async function requestAllowOnceApproval(ws: WebSocket, command: string): Promise { +async function getConnectedNodeIds(ws: WebSocket): Promise { + const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>( + ws, + "node.list", + {}, + ); + expect(nodes.ok).toBe(true); + return (nodes.payload?.nodes ?? []).filter((n) => n.connected).map((n) => n.nodeId); +} + +async function requestAllowOnceApproval( + ws: WebSocket, + command: string, + nodeId: string, +): Promise { const approvalId = crypto.randomUUID(); const requestP = rpcReq(ws, "exec.approval.request", { id: approvalId, command, + nodeId, cwd: null, host: "node", timeoutMs: 30_000, @@ -161,7 +193,10 @@ describe("node.invoke approval bypass", () => { }); }; - const connectLinuxNode = async (onInvoke: (payload: unknown) => void) => { + const connectLinuxNode = async ( + onInvoke: (payload: unknown) => void, + deviceIdentity?: DeviceIdentity, + ) => { let readyResolve: (() => void) | null = null; const ready = new Promise((resolve) => { readyResolve = resolve; @@ -180,6 +215,7 @@ describe("node.invoke approval bypass", () => { mode: GATEWAY_CLIENT_MODES.NODE, scopes: [], commands: ["system.run"], + deviceIdentity, onHelloOk: () => readyResolve?.(), onEvent: (evt) => { if (evt.event !== "node.invoke.request") { @@ -295,7 +331,7 @@ describe("node.invoke approval bypass", () => { try { const nodeId = await getConnectedNodeId(wsApprover); - const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi"); + const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi", nodeId); // Separate caller connection simulates per-call clients. const invoke = await rpcReq(wsCaller, "node.invoke", { nodeId, @@ -316,7 +352,7 @@ describe("node.invoke approval bypass", () => { expect(lastInvokeParams?.["approvalDecision"]).toBe("allow-once"); expect(lastInvokeParams?.["injected"]).toBeUndefined(); - const replayApprovalId = await requestAllowOnceApproval(wsApprover, "echo hi"); + const replayApprovalId = await requestAllowOnceApproval(wsApprover, "echo hi", nodeId); const invokeCountBeforeReplay = invokeCount; const replay = await rpcReq(wsOtherDevice, "node.invoke", { nodeId, @@ -340,4 +376,63 @@ describe("node.invoke approval bypass", () => { node.stop(); } }); + + test("blocks cross-node replay on same device", async () => { + const invokeCounts = new Map(); + const onInvoke = (payload: unknown) => { + const obj = payload as { nodeId?: unknown }; + const nodeId = typeof obj?.nodeId === "string" ? obj.nodeId : ""; + if (!nodeId) { + return; + } + invokeCounts.set(nodeId, (invokeCounts.get(nodeId) ?? 0) + 1); + }; + const nodeA = await connectLinuxNode(onInvoke, createDeviceIdentity()); + const nodeB = await connectLinuxNode(onInvoke, createDeviceIdentity()); + + const wsApprover = await connectOperator(["operator.write", "operator.approvals"]); + const wsCaller = await connectOperator(["operator.write"]); + + try { + await expect + .poll(async () => (await getConnectedNodeIds(wsApprover)).length, { + timeout: 3_000, + interval: 50, + }) + .toBeGreaterThanOrEqual(2); + const connectedNodeIds = await getConnectedNodeIds(wsApprover); + const approvedNodeId = connectedNodeIds[0] ?? ""; + const replayNodeId = connectedNodeIds.find((id) => id !== approvedNodeId) ?? ""; + expect(approvedNodeId).toBeTruthy(); + expect(replayNodeId).toBeTruthy(); + + const approvalId = await requestAllowOnceApproval(wsApprover, "echo hi", approvedNodeId); + const beforeReplayApprovedNode = invokeCounts.get(approvedNodeId) ?? 0; + const beforeReplayOtherNode = invokeCounts.get(replayNodeId) ?? 0; + const replay = await rpcReq(wsCaller, "node.invoke", { + nodeId: replayNodeId, + command: "system.run", + params: { + command: ["echo", "hi"], + rawCommand: "echo hi", + runId: approvalId, + approved: true, + approvalDecision: "allow-once", + }, + idempotencyKey: crypto.randomUUID(), + }); + expect(replay.ok).toBe(false); + expect(replay.error?.message ?? "").toContain("not valid for this node"); + await expectNoForwardedInvoke( + () => + (invokeCounts.get(approvedNodeId) ?? 0) > beforeReplayApprovedNode || + (invokeCounts.get(replayNodeId) ?? 0) > beforeReplayOtherNode, + ); + } finally { + wsApprover.close(); + wsCaller.close(); + nodeA.stop(); + nodeB.stop(); + } + }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 46617f07d7d..7af7489baf2 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -168,6 +168,9 @@ function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); } + if (request.request.nodeId) { + lines.push(`Node: ${request.request.nodeId}`); + } if (request.request.host) { lines.push(`Host: ${request.request.host}`); } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 4fd3f63470d..be4264e22ec 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -16,6 +16,7 @@ export type ExecApprovalRequest = { request: { command: string; cwd?: string | null; + nodeId?: string | null; host?: string | null; security?: string | null; ask?: string | null; From a67689a7e3ad494b6637c76235a664322d526f9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:06:34 +0000 Subject: [PATCH 178/314] fix: harden allow-always shell multiplexer wrapper handling --- CHANGELOG.md | 1 + docs/tools/exec-approvals.md | 4 +- src/infra/exec-approvals-allow-always.test.ts | 100 ++++++++++++++++++ src/infra/exec-approvals-allowlist.ts | 25 +++++ .../exec-safe-bin-runtime-policy.test.ts | 2 + src/infra/exec-safe-bin-runtime-policy.ts | 2 + src/infra/exec-wrapper-resolution.ts | 55 ++++++++++ src/infra/system-run-command.test.ts | 5 + 8 files changed, 193 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b2e4ecaae..ec759b137e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 0e6d0f52899..f155fbbd790 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -178,7 +178,9 @@ For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper -paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically. +paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`, +etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or +multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically. Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`. diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index ab43ff17ec5..640ea8706d6 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -153,6 +153,60 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).not.toContain("/usr/bin/nice"); }); + it("unwraps busybox/toybox shell applets and persists inner executables", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + makeExecutable(dir, "toybox"); + const whoami = makeExecutable(dir, "whoami"); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: `${busybox} sh -lc whoami`, + argv: [busybox, "sh", "-lc", "whoami"], + resolution: { + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }, + }, + ], + cwd: dir, + env, + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain(busybox); + }); + + it("fails closed for unsupported busybox/toybox applets", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: `${busybox} sed -n 1p`, + argv: [busybox, "sed", "-n", "1p"], + resolution: { + rawExecutable: busybox, + resolvedPath: busybox, + executableName: "busybox", + }, + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([]); + }); + it("fails closed for unresolved dispatch wrappers", () => { const patterns = resolveAllowAlwaysPatterns({ segments: [ @@ -171,6 +225,52 @@ describe("resolveAllowAlwaysPatterns", () => { expect(patterns).toEqual([]); }); + it("prevents allow-always bypass for busybox shell applets", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const busybox = makeExecutable(dir, "busybox"); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const safeBins = resolveSafeBins(undefined); + const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` }; + + const first = evaluateShellAllowlist({ + command: `${busybox} sh -c 'echo warmup-ok'`, + allowlist: [], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + const persisted = resolveAllowAlwaysPatterns({ + segments: first.segments, + cwd: dir, + env, + platform: process.platform, + }); + expect(persisted).toEqual([echo]); + + const second = evaluateShellAllowlist({ + command: `${busybox} sh -c 'id > marker'`, + allowlist: [{ pattern: echo }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(second.allowlistSatisfied).toBe(false); + expect( + requiresExecApproval({ + ask: "on-miss", + security: "allowlist", + analysisOk: second.analysisOk, + allowlistSatisfied: second.allowlistSatisfied, + }), + ).toBe(true); + }); + it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index bff632d46be..25d06994977 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -21,6 +21,7 @@ import { extractShellWrapperInlineCommand, isDispatchWrapperExecutable, isShellWrapperExecutable, + unwrapKnownShellMultiplexerInvocation, unwrapKnownDispatchWrapperInvocation, } from "./exec-wrapper-resolution.js"; @@ -299,6 +300,30 @@ function collectAllowAlwaysPatterns(params: { return; } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(params.segment.argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + collectAllowAlwaysPatterns({ + segment: { + raw: shellMultiplexerUnwrap.argv.join(" "), + argv: shellMultiplexerUnwrap.argv, + resolution: resolveCommandResolutionFromArgv( + shellMultiplexerUnwrap.argv, + params.cwd, + params.env, + ), + }, + cwd: params.cwd, + env: params.env, + platform: params.platform, + depth: params.depth + 1, + out: params.out, + }); + return; + } + const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd); if (!candidatePath) { return; diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index 29f29864be2..e9ee3230405 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -15,6 +15,8 @@ describe("exec safe-bin runtime policy", () => { { bin: "node20", expected: true }, { bin: "ruby3.2", expected: true }, { bin: "bash", expected: true }, + { bin: "busybox", expected: true }, + { bin: "toybox", expected: true }, { bin: "myfilter", expected: false }, { bin: "jq", expected: false }, ]; diff --git a/src/infra/exec-safe-bin-runtime-policy.ts b/src/infra/exec-safe-bin-runtime-policy.ts index a6f71d16f91..9ed56bfe680 100644 --- a/src/infra/exec-safe-bin-runtime-policy.ts +++ b/src/infra/exec-safe-bin-runtime-policy.ts @@ -17,6 +17,7 @@ export type ExecSafeBinConfigScope = { const INTERPRETER_LIKE_SAFE_BINS = new Set([ "ash", "bash", + "busybox", "bun", "cmd", "cmd.exe", @@ -40,6 +41,7 @@ const INTERPRETER_LIKE_SAFE_BINS = new Set([ "python3", "ruby", "sh", + "toybox", "wscript", "zsh", ]); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 58fc18b0015..55e05842e36 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -7,6 +7,7 @@ const WINDOWS_EXE_SUFFIX = ".exe"; const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const; const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const; const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const; +const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const; const DISPATCH_WRAPPER_NAMES = [ "chrt", "doas", @@ -42,6 +43,7 @@ export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPAT const POSIX_SHELL_WRAPPER_CANONICAL = new Set(POSIX_SHELL_WRAPPER_NAMES); const WINDOWS_CMD_WRAPPER_CANONICAL = new Set(WINDOWS_CMD_WRAPPER_NAMES); const POWERSHELL_WRAPPER_CANONICAL = new Set(POWERSHELL_WRAPPER_NAMES); +const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set(SHELL_MULTIPLEXER_WRAPPER_NAMES); const DISPATCH_WRAPPER_CANONICAL = new Set(DISPATCH_WRAPPER_NAMES); const SHELL_WRAPPER_CANONICAL = new Set([ ...POSIX_SHELL_WRAPPER_NAMES, @@ -133,6 +135,39 @@ function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null { return null; } +export type ShellMultiplexerUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +export function unwrapKnownShellMultiplexerInvocation( + argv: string[], +): ShellMultiplexerUnwrapResult { + const token0 = argv[0]?.trim(); + if (!token0) { + return { kind: "not-wrapper" }; + } + const wrapper = normalizeExecutableToken(token0); + if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) { + return { kind: "not-wrapper" }; + } + + let appletIndex = 1; + if (argv[appletIndex]?.trim() === "--") { + appletIndex += 1; + } + const applet = argv[appletIndex]?.trim(); + if (!applet || !isShellWrapperExecutable(applet)) { + return { kind: "blocked", wrapper }; + } + + const unwrapped = argv.slice(appletIndex); + if (unwrapped.length === 0) { + return { kind: "blocked", wrapper }; + } + return { kind: "unwrapped", wrapper, argv: unwrapped }; +} + export function isEnvAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); } @@ -474,6 +509,18 @@ function hasEnvManipulationBeforeShellWrapperInternal( ); } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return false; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return hasEnvManipulationBeforeShellWrapperInternal( + shellMultiplexerUnwrap.argv, + depth + 1, + envManipulationSeen, + ); + } + const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0)); if (!wrapper) { return false; @@ -577,6 +624,14 @@ function extractShellWrapperCommandInternal( return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1); } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv); + if (shellMultiplexerUnwrap.kind === "blocked") { + return { isWrapper: false, command: null }; + } + if (shellMultiplexerUnwrap.kind === "unwrapped") { + return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1); + } + const base0 = normalizeExecutableToken(token0); const wrapper = findShellWrapperSpec(base0); if (!wrapper) { diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 43a1b6fae79..4b99c5e1365 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -57,6 +57,11 @@ describe("system run command helpers", () => { expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date"); }); + test("extractShellCommandFromArgv unwraps busybox/toybox shell applets", () => { + expect(extractShellCommandFromArgv(["busybox", "sh", "-c", "echo hi"])).toBe("echo hi"); + expect(extractShellCommandFromArgv(["toybox", "ash", "-lc", "echo hi"])).toBe("echo hi"); + }); + test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => { expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe( null, From 64aab802015a89fab110cae158eeaa040ff11901 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:10:05 +0000 Subject: [PATCH 179/314] test(exec): add regressions for safe-bin metadata and chain semantics --- src/infra/exec-approvals.test.ts | 65 ++++++++++++++++++++++++++ src/infra/exec-safe-bin-policy.test.ts | 34 ++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index cddc8cfbdf6..49d2319dd32 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -680,6 +680,71 @@ describe("exec approvals allowlist evaluation", () => { expect(result.allowlistSatisfied).toBe(false); expect(result.segmentSatisfiedBy).toEqual([null]); }); + + it("returns empty segment details for chain misses", () => { + const segment = { + raw: "tool", + argv: ["tool"], + resolution: { + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }, + }; + const analysis = { + ok: true, + segments: [segment], + chains: [[segment]], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: "/usr/bin/other" }], + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.allowlistSatisfied).toBe(false); + expect(result.allowlistMatches).toEqual([]); + expect(result.segmentSatisfiedBy).toEqual([]); + }); + + it("aggregates segment satisfaction across chains", () => { + const allowlistSegment = { + raw: "tool", + argv: ["tool"], + resolution: { + rawExecutable: "tool", + resolvedPath: "/usr/bin/tool", + executableName: "tool", + }, + }; + const safeBinSegment = { + raw: "jq .foo", + argv: ["jq", ".foo"], + resolution: { + rawExecutable: "jq", + resolvedPath: "/usr/bin/jq", + executableName: "jq", + }, + }; + const analysis = { + ok: true, + segments: [allowlistSegment, safeBinSegment], + chains: [[allowlistSegment], [safeBinSegment]], + }; + const result = evaluateExecAllowlist({ + analysis, + allowlist: [{ pattern: "/usr/bin/tool" }], + safeBins: normalizeSafeBins(["jq"]), + cwd: "/tmp", + }); + if (process.platform === "win32") { + expect(result.allowlistSatisfied).toBe(false); + return; + } + expect(result.allowlistSatisfied).toBe(true); + expect(result.allowlistMatches.map((entry) => entry.pattern)).toEqual(["/usr/bin/tool"]); + expect(result.segmentSatisfiedBy).toEqual(["allowlist", "safeBins"]); + }); }); describe("exec approvals policy helpers", () => { diff --git a/src/infra/exec-safe-bin-policy.test.ts b/src/infra/exec-safe-bin-policy.test.ts index 886e95ccce0..285b1465e53 100644 --- a/src/infra/exec-safe-bin-policy.test.ts +++ b/src/infra/exec-safe-bin-policy.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { SAFE_BIN_PROFILE_FIXTURES, SAFE_BIN_PROFILES, + buildLongFlagPrefixMap, + collectKnownLongFlags, renderSafeBinDeniedFlagsDocBullets, validateSafeBinArgv, } from "./exec-safe-bin-policy.js"; @@ -76,6 +78,38 @@ describe("exec safe bin policy wc", () => { }); }); +describe("exec safe bin policy long-option metadata", () => { + it("precomputes long-option prefix mappings for compiled profiles", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + expect(sortProfile.knownLongFlagsSet?.has("--compress-program")).toBe(true); + expect(sortProfile.longFlagPrefixMap?.get("--compress-prog")).toBe("--compress-program"); + expect(sortProfile.longFlagPrefixMap?.get("--f")).toBe(null); + }); + + it("preserves behavior when profile metadata is missing and rebuilt at runtime", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + const withoutMetadata = { + ...sortProfile, + knownLongFlags: undefined, + knownLongFlagsSet: undefined, + longFlagPrefixMap: undefined, + }; + expect(validateSafeBinArgv(["--compress-prog=sh"], withoutMetadata)).toBe(false); + expect(validateSafeBinArgv(["--totally-unknown=1"], withoutMetadata)).toBe(false); + }); + + it("builds prefix maps from collected long flags", () => { + const sortProfile = SAFE_BIN_PROFILES.sort; + const flags = collectKnownLongFlags( + sortProfile.allowedValueFlags ?? new Set(), + sortProfile.deniedFlags ?? new Set(), + ); + const prefixMap = buildLongFlagPrefixMap(flags); + expect(prefixMap.get("--compress-pr")).toBe("--compress-program"); + expect(prefixMap.get("--f")).toBe(null); + }); +}); + describe("exec safe bin policy denied-flag matrix", () => { for (const [binName, fixture] of Object.entries(SAFE_BIN_PROFILE_FIXTURES)) { const profile = SAFE_BIN_PROFILES[binName]; From 204d9fb404838221df19cc46b30e4cf53209d038 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:11:18 +0000 Subject: [PATCH 180/314] refactor(security): dedupe shell env probe and add path regression test --- src/agents/bash-tools.exec.path.test.ts | 42 +++++++++++++++- src/infra/shell-env.test.ts | 45 ++++++++--------- src/infra/shell-env.ts | 65 +++++++++++++++---------- 3 files changed, 100 insertions(+), 52 deletions(-) diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 9bdbe07524c..5481ec9668d 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; import { captureEnv } from "../test-utils/env.js"; @@ -67,7 +70,7 @@ describe("exec PATH login shell merge", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["PATH"]); + envSnapshot = captureEnv(["PATH", "SHELL"]); }); afterEach(() => { @@ -112,6 +115,43 @@ describe("exec PATH login shell merge", () => { expect(shellPathMock).not.toHaveBeenCalled(); }); + + it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => { + if (isWin) { + return; + } + process.env.PATH = "/usr/bin"; + const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-env-")); + const unregisteredShellPath = path.join(shellDir, "unregistered-shell"); + fs.writeFileSync(unregisteredShellPath, '#!/bin/sh\nexec /bin/sh "$@"\n', { + encoding: "utf8", + mode: 0o755, + }); + process.env.SHELL = unregisteredShellPath; + + try { + const shellPathMock = vi.mocked(getShellPathFromLoginShell); + shellPathMock.mockClear(); + shellPathMock.mockImplementation((opts) => + opts.env.SHELL?.trim() === unregisteredShellPath ? null : "/custom/bin:/opt/bin", + ); + + const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); + const result = await tool.execute("call1", { command: "echo $PATH" }); + const entries = normalizePathEntries(result.content.find((c) => c.type === "text")?.text); + + expect(entries).toEqual(["/usr/bin"]); + expect(shellPathMock).toHaveBeenCalledTimes(1); + expect(shellPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + timeoutMs: 1234, + }), + ); + } finally { + fs.rmSync(shellDir, { recursive: true, force: true }); + } + }); }); describe("exec host env validation", () => { diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index ab06202dc20..644948b03c9 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -59,6 +59,23 @@ describe("shell env fallback", () => { expect(receivedEnv?.HOME).toBe(os.homedir()); } + function withEtcShells(shells: string[], fn: () => void) { + const etcShellsContent = `${shells.join("\n")}\n`; + const readFileSyncSpy = vi + .spyOn(fs, "readFileSync") + .mockImplementation((filePath, encoding) => { + if (filePath === "/etc/shells" && encoding === "utf8") { + return etcShellsContent; + } + throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); + }); + try { + fn(); + } finally { + readFileSyncSpy.mockRestore(); + } + } + it("is disabled by default", () => { expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false); expect(shouldEnableShellEnvFallback({ OPENCLAW_LOAD_SHELL_ENV: "0" })).toBe(false); @@ -172,44 +189,24 @@ describe("shell env fallback", () => { }); it("falls back to /bin/sh when SHELL is absolute but not registered in /etc/shells", () => { - const readFileSyncSpy = vi - .spyOn(fs, "readFileSync") - .mockImplementation((filePath, encoding) => { - if (filePath === "/etc/shells" && encoding === "utf8") { - return "/bin/sh\n/bin/bash\n/bin/zsh\n"; - } - throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); - }); - try { + withEtcShells(["/bin/sh", "/bin/bash", "/bin/zsh"], () => { const { res, exec } = runShellEnvFallbackForShell("/opt/homebrew/bin/evil-shell"); expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object)); - } finally { - readFileSyncSpy.mockRestore(); - } + }); }); it("uses SHELL when it is explicitly registered in /etc/shells", () => { - const readFileSyncSpy = vi - .spyOn(fs, "readFileSync") - .mockImplementation((filePath, encoding) => { - if (filePath === "/etc/shells" && encoding === "utf8") { - return "/bin/sh\n/usr/bin/zsh-trusted\n"; - } - throw new Error(`Unexpected readFileSync(${String(filePath)}) in test`); - }); - try { + withEtcShells(["/bin/sh", "/usr/bin/zsh-trusted"], () => { const trustedShell = "/usr/bin/zsh-trusted"; const { res, exec } = runShellEnvFallbackForShell(trustedShell); expect(res.ok).toBe(true); expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenCalledWith(trustedShell, ["-l", "-c", "env -0"], expect.any(Object)); - } finally { - readFileSyncSpy.mockRestore(); - } + }); }); it("sanitizes startup-related env vars before shell fallback exec", () => { diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index ac1369c48be..796c19b2666 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -110,6 +110,28 @@ function parseShellEnv(stdout: Buffer): Map { return shellEnv; } +type LoginShellEnvProbeResult = + | { ok: true; shellEnv: Map } + | { ok: false; error: string }; + +function probeLoginShellEnv(params: { + env: NodeJS.ProcessEnv; + timeoutMs?: number; + exec?: typeof execFileSync; +}): LoginShellEnvProbeResult { + const exec = params.exec ?? execFileSync; + const timeoutMs = resolveTimeoutMs(params.timeoutMs); + const shell = resolveShell(params.env); + const execEnv = resolveShellExecEnv(params.env); + + try { + const stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); + return { ok: true, shellEnv: parseShellEnv(stdout) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + export type ShellEnvFallbackResult = | { ok: true; applied: string[]; skippedReason?: never } | { ok: true; applied: []; skippedReason: "already-has-keys" | "disabled" } @@ -126,7 +148,6 @@ export type ShellEnvFallbackOptions = { export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult { const logger = opts.logger ?? console; - const exec = opts.exec ?? execFileSync; if (!opts.enabled) { lastAppliedKeys = []; @@ -139,29 +160,23 @@ export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFal return { ok: true, applied: [], skippedReason: "already-has-keys" }; } - const timeoutMs = resolveTimeoutMs(opts.timeoutMs); - - const shell = resolveShell(opts.env); - const execEnv = resolveShellExecEnv(opts.env); - - let stdout: Buffer; - try { - stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.warn(`[openclaw] shell env fallback failed: ${msg}`); + const probe = probeLoginShellEnv({ + env: opts.env, + timeoutMs: opts.timeoutMs, + exec: opts.exec, + }); + if (!probe.ok) { + logger.warn(`[openclaw] shell env fallback failed: ${probe.error}`); lastAppliedKeys = []; - return { ok: false, error: msg, applied: [] }; + return { ok: false, error: probe.error, applied: [] }; } - const shellEnv = parseShellEnv(stdout); - const applied: string[] = []; for (const key of opts.expectedKeys) { if (opts.env[key]?.trim()) { continue; } - const value = shellEnv.get(key); + const value = probe.shellEnv.get(key); if (!value?.trim()) { continue; } @@ -208,21 +223,17 @@ export function getShellPathFromLoginShell(opts: { return cachedShellPath; } - const exec = opts.exec ?? execFileSync; - const timeoutMs = resolveTimeoutMs(opts.timeoutMs); - const shell = resolveShell(opts.env); - const execEnv = resolveShellExecEnv(opts.env); - - let stdout: Buffer; - try { - stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); - } catch { + const probe = probeLoginShellEnv({ + env: opts.env, + timeoutMs: opts.timeoutMs, + exec: opts.exec, + }); + if (!probe.ok) { cachedShellPath = null; return cachedShellPath; } - const shellEnv = parseShellEnv(stdout); - const shellPath = shellEnv.get("PATH")?.trim(); + const shellPath = probe.shellEnv.get("PATH")?.trim(); cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null; return cachedShellPath; } From ffd63b7a2c4c6d5aeb4710ef951d5794ad7ad77b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:12:22 +0000 Subject: [PATCH 181/314] fix(security): trust resolved skill-bin paths in allowlist auto-allow --- CHANGELOG.md | 2 +- src/infra/exec-approvals-allowlist.ts | 93 ++++++++++++++++++++----- src/infra/exec-approvals.test.ts | 6 +- src/node-host/invoke-system-run.test.ts | 84 +++++++++++++++++++++- src/node-host/invoke-system-run.ts | 5 +- src/node-host/invoke-types.ts | 4 +- src/node-host/runner.ts | 81 +++++++++++++++++++-- 7 files changed, 243 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec759b137e0..39a2596febb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: harden `autoAllowSkills` matching to require pathless invocations with resolved executables, blocking `./`/absolute-path basename collisions from satisfying skill auto-allow checks under allowlist mode. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @akhmittra for reporting. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 25d06994977..687ce3039ba 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { DEFAULT_SAFE_BINS, analyzeShellCommand, @@ -104,6 +105,71 @@ export type ExecAllowlistEvaluation = { }; export type ExecSegmentSatisfiedBy = "allowlist" | "safeBins" | "skills" | null; +export type SkillBinTrustEntry = { + name: string; + resolvedPath: string; +}; + +function normalizeSkillBinName(value: string | undefined): string | null { + const trimmed = value?.trim().toLowerCase(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function normalizeSkillBinResolvedPath(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const resolved = path.resolve(trimmed); + if (process.platform === "win32") { + return resolved.replace(/\\/g, "/").toLowerCase(); + } + return resolved; +} + +function buildSkillBinTrustIndex( + entries: readonly SkillBinTrustEntry[] | undefined, +): Map> { + const trustByName = new Map>(); + if (!entries || entries.length === 0) { + return trustByName; + } + for (const entry of entries) { + const name = normalizeSkillBinName(entry.name); + const resolvedPath = normalizeSkillBinResolvedPath(entry.resolvedPath); + if (!name || !resolvedPath) { + continue; + } + const paths = trustByName.get(name) ?? new Set(); + paths.add(resolvedPath); + trustByName.set(name, paths); + } + return trustByName; +} + +function isSkillAutoAllowedSegment(params: { + segment: ExecCommandSegment; + allowSkills: boolean; + skillBinTrust: ReadonlyMap>; +}): boolean { + if (!params.allowSkills) { + return false; + } + const resolution = params.segment.resolution; + if (!resolution?.resolvedPath) { + return false; + } + const rawExecutable = resolution.rawExecutable?.trim() ?? ""; + if (!rawExecutable || isPathScopedExecutableToken(rawExecutable)) { + return false; + } + const executableName = normalizeSkillBinName(resolution.executableName); + const resolvedPath = normalizeSkillBinResolvedPath(resolution.resolvedPath); + if (!executableName || !resolvedPath) { + return false; + } + return Boolean(params.skillBinTrust.get(executableName)?.has(resolvedPath)); +} function evaluateSegments( segments: ExecCommandSegment[], @@ -114,7 +180,7 @@ function evaluateSegments( cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; }, ): { @@ -123,7 +189,8 @@ function evaluateSegments( segmentSatisfiedBy: ExecSegmentSatisfiedBy[]; } { const matches: ExecAllowlistEntry[] = []; - const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0; + const skillBinTrust = buildSkillBinTrustIndex(params.skillBins); + const allowSkills = params.autoAllowSkills === true && skillBinTrust.size > 0; const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = []; const satisfied = segments.every((segment) => { @@ -152,19 +219,11 @@ function evaluateSegments( platform: params.platform, trustedSafeBinDirs: params.trustedSafeBinDirs, }); - const rawExecutable = segment.resolution?.rawExecutable?.trim() ?? ""; - const executableName = segment.resolution?.executableName; - const usesExplicitPath = isPathScopedExecutableToken(rawExecutable); - let skillAllow = false; - if ( - allowSkills && - segment.resolution?.resolvedPath && - rawExecutable.length > 0 && - !usesExplicitPath && - executableName - ) { - skillAllow = Boolean(params.skillBins?.has(executableName)); - } + const skillAllow = isSkillAutoAllowedSegment({ + segment, + allowSkills, + skillBinTrust, + }); const by: ExecSegmentSatisfiedBy = match ? "allowlist" : safe @@ -194,7 +253,7 @@ export function evaluateExecAllowlist(params: { cwd?: string; platform?: string | null; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; }): ExecAllowlistEvaluation { const allowlistMatches: ExecAllowlistEntry[] = []; @@ -393,7 +452,7 @@ export function evaluateShellAllowlist(params: { cwd?: string; env?: NodeJS.ProcessEnv; trustedSafeBinDirs?: ReadonlySet; - skillBins?: Set; + skillBins?: readonly SkillBinTrustEntry[]; autoAllowSkills?: boolean; platform?: string | null; }): ExecAllowlistAnalysis { diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index 49d2319dd32..6b405b466d3 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -621,7 +621,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); @@ -647,7 +647,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/tmp/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); @@ -673,7 +673,7 @@ describe("exec approvals allowlist evaluation", () => { analysis, allowlist: [], safeBins: new Set(), - skillBins: new Set(["skill-bin"]), + skillBins: [{ name: "skill-bin", resolvedPath: "/opt/skills/skill-bin" }], autoAllowSkills: true, cwd: "/tmp", }); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 410382a5aad..2c6c55bd1ab 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { saveExecApprovals } from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; @@ -49,7 +50,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => new Set(), + current: async () => [], }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -187,7 +188,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sessionKey: "agent:main:main", }, skillBins: { - current: async () => new Set(), + current: async () => [], }, execHostEnforced: false, execHostFallbackAllowed: true, @@ -226,6 +227,85 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); + it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skill-path-spoof-")); + const previousOpenClawHome = process.env.OPENCLAW_HOME; + const skillBinPath = path.join(tempHome, "skill-bin"); + fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); + fs.chmodSync(skillBinPath, 0o755); + process.env.OPENCLAW_HOME = tempHome; + saveExecApprovals({ + version: 1, + defaults: { + security: "allowlist", + ask: "on-miss", + askFallback: "deny", + autoAllowSkills: true, + }, + agents: {}, + }); + const runCommand = vi.fn(async () => ({ + success: true, + stdout: "local-ok", + stderr: "", + timedOut: false, + truncated: false, + exitCode: 0, + error: null, + })); + const sendInvokeResult = vi.fn(async () => {}); + const sendNodeEvent = vi.fn(async () => {}); + + try { + await handleSystemRunInvoke({ + client: {} as never, + params: { + command: ["./skill-bin", "--help"], + cwd: tempHome, + sessionKey: "agent:main:main", + }, + skillBins: { + current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }], + }, + execHostEnforced: false, + execHostFallbackAllowed: true, + resolveExecSecurity: () => "allowlist", + resolveExecAsk: () => "on-miss", + isCmdExeInvocation: () => false, + sanitizeEnv: () => undefined, + runCommand, + runViaMacAppExecHost: vi.fn(async () => null), + sendNodeEvent, + buildExecEventPayload: (payload) => payload, + sendInvokeResult, + sendExecFinishedEvent: vi.fn(async () => {}), + preferMacAppExecHost: false, + }); + } finally { + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + expect(runCommand).not.toHaveBeenCalled(); + expect(sendNodeEvent).toHaveBeenCalledWith( + expect.anything(), + "exec.denied", + expect.objectContaining({ reason: "approval-required" }), + ); + expect(sendInvokeResult).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: "SYSTEM_RUN_DENIED: approval required", + }), + }), + ); + }); + it("denies env -S shell payloads in allowlist mode", async () => { const { runCommand, sendInvokeResult } = await runSystemInvoke({ preferMacAppExecHost: false, diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index aeef1522fcc..da97464966a 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -14,6 +14,7 @@ import { type ExecAsk, type ExecCommandSegment, type ExecSecurity, + type SkillBinTrustEntry, } from "../infra/exec-approvals.js"; import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js"; import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; @@ -145,7 +146,7 @@ function evaluateSystemRunAllowlist(params: { trustedSafeBinDirs: ReturnType["trustedSafeBinDirs"]; cwd: string | undefined; env: Record | undefined; - skillBins: Set; + skillBins: SkillBinTrustEntry[]; autoAllowSkills: boolean; }): SystemRunAllowlistAnalysis { if (params.shellCommand) { @@ -310,7 +311,7 @@ export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): global: cfg.tools?.exec, local: agentExec, }); - const bins = autoAllowSkills ? await opts.skillBins.current() : new Set(); + const bins = autoAllowSkills ? await opts.skillBins.current() : []; let { analysisOk, allowlistMatches, allowlistSatisfied, segments } = evaluateSystemRunAllowlist({ shellCommand, argv, diff --git a/src/node-host/invoke-types.ts b/src/node-host/invoke-types.ts index ae41d56b961..7246ba2925f 100644 --- a/src/node-host/invoke-types.ts +++ b/src/node-host/invoke-types.ts @@ -1,3 +1,5 @@ +import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; + export type SystemRunParams = { command: string[]; rawCommand?: string | null; @@ -35,5 +37,5 @@ export type ExecEventPayload = { }; export type SkillBinsProvider = { - current(force?: boolean): Promise>; + current(force?: boolean): Promise; }; diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index e8b5df74f0e..edf2cc12215 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,7 +1,10 @@ +import fs from "node:fs"; +import path from "node:path"; import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig } from "../config/config.js"; import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -27,17 +30,83 @@ type NodeHostRunOptions = { const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +function isExecutableFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return false; + } + if (process.platform !== "win32") { + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null { + if (bin.includes("/") || bin.includes("\\")) { + return null; + } + const hasExtension = process.platform === "win32" && path.extname(bin).length > 0; + const extensions = + process.platform === "win32" + ? hasExtension + ? [""] + : (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((ext) => ext.toLowerCase()) + : [""]; + for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) { + for (const ext of extensions) { + const candidate = path.join(dir, bin + ext); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + return null; +} + +function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] { + const trustEntries: SkillBinTrustEntry[] = []; + const seen = new Set(); + for (const bin of bins) { + const name = bin.trim(); + if (!name) { + continue; + } + const resolvedPath = resolveExecutablePathFromEnv(name, pathEnv); + if (!resolvedPath) { + continue; + } + const key = `${name}\u0000${resolvedPath}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + trustEntries.push({ name, resolvedPath }); + } + return trustEntries.toSorted( + (left, right) => + left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath), + ); +} + class SkillBinsCache implements SkillBinsProvider { - private bins = new Set(); + private bins: SkillBinTrustEntry[] = []; private lastRefresh = 0; private readonly ttlMs = 90_000; private readonly fetch: () => Promise; + private readonly pathEnv: string; - constructor(fetch: () => Promise) { + constructor(fetch: () => Promise, pathEnv: string) { this.fetch = fetch; + this.pathEnv = pathEnv; } - async current(force = false): Promise> { + async current(force = false): Promise { if (force || Date.now() - this.lastRefresh > this.ttlMs) { await this.refresh(); } @@ -47,11 +116,11 @@ class SkillBinsCache implements SkillBinsProvider { private async refresh() { try { const bins = await this.fetch(); - this.bins = new Set(bins); + this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv); this.lastRefresh = Date.now(); } catch { if (!this.lastRefresh) { - this.bins = new Set(); + this.bins = []; } } } @@ -155,7 +224,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const res = await client.request<{ bins: Array }>("skills.bins", {}); const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : []; return bins; - }); + }, pathEnv); client.start(); await new Promise(() => {}); From c6c1e3e7cf7f3f13e31d69177949ca85172cb2f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:13:48 +0000 Subject: [PATCH 182/314] docs(changelog): correct exec approvals reporter credit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a2596febb..2d95bdeba84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @akhmittra for reporting. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. From 6f0dd61795122be95079b8afa020a47e15fcf1af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:16:26 +0000 Subject: [PATCH 183/314] fix(exec): restore two-phase approval registration flow --- .../bash-tools.exec-approval-request.test.ts | 40 ++++++- .../bash-tools.exec-approval-request.ts | 110 ++++++++++++++++-- src/agents/bash-tools.exec-host-gateway.ts | 45 ++++--- src/agents/bash-tools.exec-host-node.ts | 45 ++++--- .../bash-tools.exec.approval-id.test.ts | 11 +- 5 files changed, 211 insertions(+), 40 deletions(-) diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index a0722002c64..1cd221e300e 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -22,7 +22,13 @@ describe("requestExecApprovalDecision", () => { }); it("returns string decisions", async () => { - vi.mocked(callGatewayTool).mockResolvedValue({ decision: "allow-once" }); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "approval-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockResolvedValueOnce({ decision: "allow-once" }); const result = await requestExecApprovalDecision({ id: "approval-id", @@ -52,12 +58,22 @@ describe("requestExecApprovalDecision", () => { resolvedPath: "/usr/bin/echo", sessionKey: "session", timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, }, + { expectFinal: false }, + ); + expect(callGatewayTool).toHaveBeenNthCalledWith( + 2, + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id: "approval-id" }, ); }); it("returns null for missing or non-string decisions", async () => { - vi.mocked(callGatewayTool).mockResolvedValueOnce({}); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ status: "accepted", id: "approval-id", expiresAtMs: 1234 }) + .mockResolvedValueOnce({}); await expect( requestExecApprovalDecision({ id: "approval-id", @@ -70,7 +86,9 @@ describe("requestExecApprovalDecision", () => { }), ).resolves.toBeNull(); - vi.mocked(callGatewayTool).mockResolvedValueOnce({ decision: 123 }); + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ status: "accepted", id: "approval-id-2", expiresAtMs: 1234 }) + .mockResolvedValueOnce({ decision: 123 }); await expect( requestExecApprovalDecision({ id: "approval-id-2", @@ -83,4 +101,20 @@ describe("requestExecApprovalDecision", () => { }), ).resolves.toBeNull(); }); + + it("returns final decision directly when gateway already replies with decision", async () => { + vi.mocked(callGatewayTool).mockResolvedValue({ decision: "deny", id: "approval-id" }); + + const result = await requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }); + + expect(result).toBe("deny"); + expect(vi.mocked(callGatewayTool).mock.calls).toHaveLength(1); + }); }); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 7f0b59736d5..83323845c0c 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -18,10 +18,45 @@ export type RequestExecApprovalDecisionParams = { sessionKey?: string; }; -export async function requestExecApprovalDecision( +type ParsedDecision = { present: boolean; value: string | null }; + +function parseDecision(value: unknown): ParsedDecision { + if (!value || typeof value !== "object") { + return { present: false, value: null }; + } + // Distinguish "field missing" from "field present but null/invalid". + // Registration responses intentionally omit `decision`; decision waits can include it. + if (!Object.hasOwn(value, "decision")) { + return { present: false, value: null }; + } + const decision = (value as { decision?: unknown }).decision; + return { present: true, value: typeof decision === "string" ? decision : null }; +} + +function parseString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseExpiresAtMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export type ExecApprovalRegistration = { + id: string; + expiresAtMs: number; + finalDecision?: string | null; +}; + +export async function registerExecApprovalRequest( params: RequestExecApprovalDecisionParams, -): Promise { - const decisionResult = await callGatewayTool<{ decision: string }>( +): Promise { + // Two-phase registration is critical: the ID must be registered server-side + // before exec returns `approval-pending`, otherwise `/approve` can race and orphan. + const registrationResult = await callGatewayTool<{ + id?: string; + expiresAtMs?: number; + decision?: string; + }>( "exec.approval.request", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, { @@ -36,13 +71,46 @@ export async function requestExecApprovalDecision( resolvedPath: params.resolvedPath, sessionKey: params.sessionKey, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, }, + { expectFinal: false }, ); - const decisionValue = - decisionResult && typeof decisionResult === "object" - ? (decisionResult as { decision?: unknown }).decision - : undefined; - return typeof decisionValue === "string" ? decisionValue : null; + const decision = parseDecision(registrationResult); + const id = parseString(registrationResult?.id) ?? params.id; + const expiresAtMs = + parseExpiresAtMs(registrationResult?.expiresAtMs) ?? Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + if (decision.present) { + return { id, expiresAtMs, finalDecision: decision.value }; + } + return { id, expiresAtMs }; +} + +export async function waitForExecApprovalDecision(id: string): Promise { + try { + const decisionResult = await callGatewayTool<{ decision: string }>( + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id }, + ); + return parseDecision(decisionResult).value; + } catch (err) { + // Timeout/cleanup path: treat missing/expired as no decision so askFallback applies. + const message = String(err).toLowerCase(); + if (message.includes("approval expired or not found")) { + return null; + } + throw err; + } +} + +export async function requestExecApprovalDecision( + params: RequestExecApprovalDecisionParams, +): Promise { + const registration = await registerExecApprovalRequest(params); + if (Object.hasOwn(registration, "finalDecision")) { + return registration.finalDecision ?? null; + } + return await waitForExecApprovalDecision(registration.id); } export async function requestExecApprovalDecisionForHost(params: { @@ -70,3 +138,29 @@ export async function requestExecApprovalDecisionForHost(params: { sessionKey: params.sessionKey, }); } + +export async function registerExecApprovalRequestForHost(params: { + approvalId: string; + command: string; + workdir: string; + host: "gateway" | "node"; + nodeId?: string; + security: ExecSecurity; + ask: ExecAsk; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; +}): Promise { + return await registerExecApprovalRequest({ + id: params.approvalId, + command: params.command, + cwd: params.workdir, + nodeId: params.nodeId, + host: params.host, + security: params.security, + ask: params.ask, + agentId: params.agentId, + resolvedPath: params.resolvedPath, + sessionKey: params.sessionKey, + }); +} diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index be81e703e13..60711910975 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -17,7 +17,10 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; import { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; -import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; +import { + registerExecApprovalRequestForHost, + waitForExecApprovalDecision, +} from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_NOTIFY_TAIL_CHARS, @@ -135,28 +138,42 @@ export async function processGatewayAllowlist( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const effectiveTimeout = typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec; const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + let preResolvedDecision: string | null | undefined; + + try { + // Register first so the returned approval ID is actionable immediately. + const registration = await registerExecApprovalRequestForHost({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "gateway", + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + resolvedPath, + sessionKey: params.sessionKey, + }); + expiresAtMs = registration.expiresAtMs; + preResolvedDecision = registration.finalDecision; + } catch (err) { + throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err }); + } void (async () => { - let decision: string | null = null; + let decision: string | null = preResolvedDecision ?? null; try { - decision = await requestExecApprovalDecisionForHost({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "gateway", - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - resolvedPath, - sessionKey: params.sessionKey, - }); + // Some gateways may return a final decision inline during registration. + // Only call waitDecision when registration did not already carry one. + if (preResolvedDecision === undefined) { + decision = await waitForExecApprovalDecision(approvalId); + } } catch { emitExecSystemEvent( `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec-host-node.ts b/src/agents/bash-tools.exec-host-node.ts index fc6893b93bf..5a45c869292 100644 --- a/src/agents/bash-tools.exec-host-node.ts +++ b/src/agents/bash-tools.exec-host-node.ts @@ -14,7 +14,10 @@ import { import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js"; import { buildNodeShellCommand } from "../infra/node-shell.js"; import { logInfo } from "../logger.js"; -import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js"; +import { + registerExecApprovalRequestForHost, + waitForExecApprovalDecision, +} from "./bash-tools.exec-approval-request.js"; import { DEFAULT_APPROVAL_TIMEOUT_MS, createApprovalSlug, @@ -180,25 +183,39 @@ export async function executeNodeHostCommand( if (requiresAsk) { const approvalId = crypto.randomUUID(); const approvalSlug = createApprovalSlug(approvalId); - const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const contextKey = `exec:${approvalId}`; const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)); const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : ""; + let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; + let preResolvedDecision: string | null | undefined; + + try { + // Register first so the returned approval ID is actionable immediately. + const registration = await registerExecApprovalRequestForHost({ + approvalId, + command: params.command, + workdir: params.workdir, + host: "node", + nodeId, + security: hostSecurity, + ask: hostAsk, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); + expiresAtMs = registration.expiresAtMs; + preResolvedDecision = registration.finalDecision; + } catch (err) { + throw new Error(`Exec approval registration failed: ${String(err)}`, { cause: err }); + } void (async () => { - let decision: string | null = null; + let decision: string | null = preResolvedDecision ?? null; try { - decision = await requestExecApprovalDecisionForHost({ - approvalId, - command: params.command, - workdir: params.workdir, - host: "node", - nodeId, - security: hostSecurity, - ask: hostAsk, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); + // Some gateways may return a final decision inline during registration. + // Only call waitDecision when registration did not already carry one. + if (preResolvedDecision === undefined) { + decision = await waitForExecApprovalDecision(approvalId); + } } catch { emitExecSystemEvent( `Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${params.command}`, diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 4fb5b4bf495..37a1215e5e6 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -65,7 +65,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { if (method === "exec.approval.request") { - // Approval request now carries the decision directly. + return { status: "accepted", id: (params as { id?: string })?.id }; + } + if (method === "exec.approval.waitDecision") { return { decision: "allow-once" }; } if (method === "node.invoke") { @@ -191,6 +193,7 @@ describe("exec approvals", () => { expect(result.details.status).toBe("approval-pending"); await approvalSeen; expect(calls).toContain("exec.approval.request"); + expect(calls).toContain("exec.approval.waitDecision"); }); it("denies node obfuscated command when approval request times out", async () => { @@ -204,6 +207,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method) => { calls.push(method); if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { return {}; } if (method === "node.invoke") { @@ -237,6 +243,9 @@ describe("exec approvals", () => { vi.mocked(callGatewayTool).mockImplementation(async (method) => { if (method === "exec.approval.request") { + return { status: "accepted", id: "approval-id" }; + } + if (method === "exec.approval.waitDecision") { return {}; } return { ok: true }; From 7b2b86c60abd7699cd9a19a66cd5ac994cc9acc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:22:05 +0000 Subject: [PATCH 184/314] fix(exec): add approval race changelog and regressions --- CHANGELOG.md | 1 + .../bash-tools.exec-approval-request.test.ts | 49 +++++++++++++++ .../bash-tools.exec.approval-id.test.ts | 62 +++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d95bdeba84..9a732763f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 1cd221e300e..c14a3f62b91 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -102,6 +102,55 @@ describe("requestExecApprovalDecision", () => { ).resolves.toBeNull(); }); + it("uses registration response id when waiting for decision", async () => { + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "server-assigned-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockResolvedValueOnce({ decision: "allow-once" }); + + await expect( + requestExecApprovalDecision({ + id: "client-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBe("allow-once"); + + expect(callGatewayTool).toHaveBeenNthCalledWith( + 2, + "exec.approval.waitDecision", + { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, + { id: "server-assigned-id" }, + ); + }); + + it("treats expired-or-missing waitDecision as null decision", async () => { + vi.mocked(callGatewayTool) + .mockResolvedValueOnce({ + status: "accepted", + id: "approval-id", + expiresAtMs: DEFAULT_APPROVAL_TIMEOUT_MS, + }) + .mockRejectedValueOnce(new Error("approval expired or not found")); + + await expect( + requestExecApprovalDecision({ + id: "approval-id", + command: "echo hi", + cwd: "/tmp", + host: "gateway", + security: "allowlist", + ask: "on-miss", + }), + ).resolves.toBeNull(); + }); + it("returns final decision directly when gateway already replies with decision", async () => { vi.mocked(callGatewayTool).mockResolvedValue({ decision: "deny", id: "approval-id" }); diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 37a1215e5e6..fc04efc0a63 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -196,6 +196,68 @@ describe("exec approvals", () => { expect(calls).toContain("exec.approval.waitDecision"); }); + it("waits for approval registration before returning approval-pending", async () => { + const calls: string[] = []; + let resolveRegistration: ((value: unknown) => void) | undefined; + const registrationPromise = new Promise((resolve) => { + resolveRegistration = resolve; + }); + + vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => { + calls.push(method); + if (method === "exec.approval.request") { + return await registrationPromise; + } + if (method === "exec.approval.waitDecision") { + return { decision: "deny" }; + } + return { ok: true, id: (params as { id?: string })?.id }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + let settled = false; + const executePromise = tool.execute("call-registration-gate", { command: "echo register" }); + void executePromise.finally(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveRegistration?.({ status: "accepted", id: "approval-id" }); + const result = await executePromise; + expect(result.details.status).toBe("approval-pending"); + expect(calls[0]).toBe("exec.approval.request"); + expect(calls).toContain("exec.approval.waitDecision"); + }); + + it("fails fast when approval registration fails", async () => { + vi.mocked(callGatewayTool).mockImplementation(async (method) => { + if (method === "exec.approval.request") { + throw new Error("gateway offline"); + } + return { ok: true }; + }); + + const tool = createExecTool({ + host: "gateway", + ask: "on-miss", + security: "allowlist", + approvalRunningNoticeMs: 0, + }); + + await expect(tool.execute("call-registration-fail", { command: "echo fail" })).rejects.toThrow( + "Exec approval registration failed", + ); + }); + it("denies node obfuscated command when approval request times out", async () => { vi.mocked(detectCommandObfuscation).mockReturnValue({ detected: true, From 177f167eab203c659bb759dfb70eb8eba86d3c9d Mon Sep 17 00:00:00 2001 From: Omair Afzal <32237905+omair445@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:22:39 +0500 Subject: [PATCH 185/314] fix: guard .trim() calls on potentially undefined workspaceDir (#24875) Change workspaceDir param type from string to string | undefined in resolvePluginSkillDirs and use nullish coalescing before .trim() to prevent TypeError when workspaceDir is undefined. --- src/agents/skills/plugin-skills.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index c3e7999fe87..90c8711cd74 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -12,10 +12,10 @@ import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; const log = createSubsystemLogger("skills"); export function resolvePluginSkillDirs(params: { - workspaceDir: string; + workspaceDir: string | undefined; config?: OpenClawConfig; }): string[] { - const workspaceDir = params.workspaceDir.trim(); + const workspaceDir = (params.workspaceDir ?? "").trim(); if (!workspaceDir) { return []; } From 19c43eade2ea227f7eb6ac9868d819fae0f00225 Mon Sep 17 00:00:00 2001 From: Omair Afzal <32237905+omair445@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:22:42 +0500 Subject: [PATCH 186/314] fix(memory): strip null bytes from workspace paths causing ENOTDIR (#24876) Add stripNullBytes() helper and apply it to all return paths in resolveAgentWorkspaceDir() including configured, default, and state-dir-derived paths. Null bytes in paths cause ENOTDIR errors when Node tries to resolve them as directories. --- src/agents/agent-scope.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index c48cea9f690..31fe49c0b76 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -13,6 +13,12 @@ import { normalizeSkillFilter } from "./skills/filter.js"; import { resolveDefaultAgentWorkspaceDir } from "./workspace.js"; const log = createSubsystemLogger("agent-scope"); +/** Strip null bytes from paths to prevent ENOTDIR errors. */ +function stripNullBytes(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\0/g, ""); +} + export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; type AgentEntry = NonNullable["list"]>[number]; @@ -214,18 +220,18 @@ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { const id = normalizeAgentId(agentId); const configured = resolveAgentConfig(cfg, id)?.workspace?.trim(); if (configured) { - return resolveUserPath(configured); + return stripNullBytes(resolveUserPath(configured)); } const defaultAgentId = resolveDefaultAgentId(cfg); if (id === defaultAgentId) { const fallback = cfg.agents?.defaults?.workspace?.trim(); if (fallback) { - return resolveUserPath(fallback); + return stripNullBytes(resolveUserPath(fallback)); } - return resolveDefaultAgentWorkspaceDir(process.env); + return stripNullBytes(resolveDefaultAgentWorkspaceDir(process.env)); } const stateDir = resolveStateDir(process.env); - return path.join(stateDir, `workspace-${id}`); + return stripNullBytes(path.join(stateDir, `workspace-${id}`)); } export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { From e2e10b3da49dcd75263a48c2198908d7027cf946 Mon Sep 17 00:00:00 2001 From: David Murray Date: Mon, 23 Feb 2026 19:22:45 -0800 Subject: [PATCH 187/314] fix(slack): map threadId to replyToId for restart sentinel notifications (#24885) The restart sentinel wake path passes threadId to deliverOutboundPayloads, but Slack requires replyToId (mapped to thread_ts) for threading. The agent reply path already does this conversion but the sentinel path did not, causing post-restart notifications to land as top-level DMs. Fixes #17716 --- src/gateway/server-restart-sentinel.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index e536193accd..454657d188d 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -76,13 +76,22 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) { sessionThreadId ?? (origin?.threadId != null ? String(origin.threadId) : undefined); + // Slack uses replyToId (thread_ts) for threading, not threadId. + // The reply path does this mapping but deliverOutboundPayloads does not, + // so we must convert here to ensure post-restart notifications land in + // the originating Slack thread. See #17716. + const isSlack = channel === "slack"; + const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined; + const resolvedThreadId = isSlack ? undefined : threadId; + try { await deliverOutboundPayloads({ cfg, channel, to: resolved.to, accountId: origin?.accountId, - threadId, + replyToId, + threadId: resolvedThreadId, payloads: [{ text: message }], agentId: resolveSessionAgentId({ sessionKey, config: cfg }), bestEffort: true, From 588ad7fb381a851b6c73c496eb069560eee0a33c Mon Sep 17 00:00:00 2001 From: Bill Cropper Date: Mon, 23 Feb 2026 22:22:48 -0500 Subject: [PATCH 188/314] fix: respect agent model config in slug generator (#24776) The slug generator was using hardcoded DEFAULT_PROVIDER and DEFAULT_MODEL instead of resolving from agent config. This caused it to fall back to anthropic/claude-opus-4-6 even when a cloud model was configured. Now uses resolveAgentModelPrimary() to get the configured model, with fallback to defaults if not configured. Fixes issue where session memory filenames would fail to generate when using cloud models that require special backends. --- src/hooks/llm-slug-generator.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index f104cc4a7b8..33c69dcf5ed 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -9,7 +9,10 @@ import { resolveDefaultAgentId, resolveAgentWorkspaceDir, resolveAgentDir, + resolveAgentModelPrimary, } from "../agents/agent-scope.js"; +import { DEFAULT_PROVIDER, DEFAULT_MODEL } from "../agents/defaults.js"; +import { parseModelRef } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -41,6 +44,12 @@ ${params.sessionContent.slice(0, 2000)} Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`; + // Resolve model from agent config instead of using hardcoded defaults + const modelRef = resolveAgentModelPrimary(params.cfg, agentId); + const parsed = modelRef ? parseModelRef(modelRef, DEFAULT_PROVIDER) : null; + const provider = parsed?.provider ?? DEFAULT_PROVIDER; + const model = parsed?.model ?? DEFAULT_MODEL; + const result = await runEmbeddedPiAgent({ sessionId: `slug-generator-${Date.now()}`, sessionKey: "temp:slug-generator", @@ -50,6 +59,8 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", agentDir, config: params.cfg, prompt, + provider, + model, timeoutMs: 15_000, // 15 second timeout runId: `slug-gen-${Date.now()}`, }); From 70cfb69a5f12a47f95537e58e0726c2395dbdb20 Mon Sep 17 00:00:00 2001 From: Soumik Bhatta Date: Mon, 23 Feb 2026 22:22:52 -0500 Subject: [PATCH 189/314] fix(doctor): skip false positive permission warnings for Nix store symlinks (#24901) On NixOS/Nix-managed installs, config and state directories are symlinks into /nix/store/. Symlinks on Linux always report 0o777 via lstatSync, causing `openclaw doctor` to incorrectly warn about open permissions. Use lstatSync to detect symlinks, resolve the target, and only suppress the warning when the resolved path lives in /nix/store/ (an immutable filesystem). Symlinks to insecure targets still trigger warnings. Co-authored-by: Claude Opus 4.6 --- src/commands/doctor-state-integrity.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index bccb04964eb..2e31da8e76a 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -261,8 +261,15 @@ export async function noteStateIntegrity( } if (stateDirExists && process.platform !== "win32") { try { - const stat = fs.statSync(stateDir); - if ((stat.mode & 0o077) !== 0) { + const dirLstat = fs.lstatSync(stateDir); + const isDirSymlink = dirLstat.isSymbolicLink(); + // For symlinks, check the resolved target permissions instead of the + // symlink itself (which always reports 777). Skip the warning only when + // the target lives in a known immutable store (e.g. /nix/store/). + const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat; + const resolvedDir = isDirSymlink ? fs.realpathSync(stateDir) : stateDir; + const isImmutableStore = resolvedDir.startsWith("/nix/store/"); + if (!isImmutableStore && (stat.mode & 0o077) !== 0) { warnings.push( `- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`, ); @@ -282,10 +289,14 @@ export async function noteStateIntegrity( if (configPath && existsFile(configPath) && process.platform !== "win32") { try { - const linkStat = fs.lstatSync(configPath); - const stat = fs.statSync(configPath); - const isSymlink = linkStat.isSymbolicLink(); - if (!isSymlink && (stat.mode & 0o077) !== 0) { + const configLstat = fs.lstatSync(configPath); + const isSymlink = configLstat.isSymbolicLink(); + // For symlinks, check the resolved target permissions. Skip the warning + // only when the target lives in an immutable store (e.g. /nix/store/). + const stat = isSymlink ? fs.statSync(configPath) : configLstat; + const resolvedConfig = isSymlink ? fs.realpathSync(configPath) : configPath; + const isImmutableConfig = resolvedConfig.startsWith("/nix/store/"); + if (!isImmutableConfig && (stat.mode & 0o077) !== 0) { warnings.push( `- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`, ); From dc8423f2c0dc6bc2a7708402c494667185d85341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Tue, 24 Feb 2026 11:22:55 +0800 Subject: [PATCH 190/314] fix: back up existing systemd unit before overwriting on update (#24350) (#24937) When `openclaw update` regenerates the systemd service file, any user customizations to ExecStart (e.g. proxychains4 wrapper) are silently lost. Now the existing unit file is copied to `.bak` before writing the new one, so users can restore their customizations. The backup path is printed in the install output so users are aware. Co-authored-by: echoVic --- src/daemon/systemd.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 0a295436df8..0e1dc5541ba 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -193,6 +193,18 @@ export async function installSystemdService({ const unitPath = resolveSystemdUnitPath(env); await fs.mkdir(path.dirname(unitPath), { recursive: true }); + + // Preserve user customizations: back up existing unit file before overwriting. + let backedUp = false; + try { + await fs.access(unitPath); + const backupPath = `${unitPath}.bak`; + await fs.copyFile(unitPath, backupPath); + backedUp = true; + } catch { + // File does not exist yet — nothing to back up. + } + const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); const unit = buildSystemdUnit({ description: serviceDescription, @@ -227,6 +239,14 @@ export async function installSystemdService({ label: "Installed systemd service", value: unitPath, }, + ...(backedUp + ? [ + { + label: "Previous unit backed up to", + value: `${unitPath}.bak`, + }, + ] + : []), ], { leadingBlankLine: true }, ); From d07d24eebefe1d919260555f7deb71825778ee39 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 23 Feb 2026 19:22:58 -0800 Subject: [PATCH 191/314] fix: clamp poll sleep duration to non-negative in bash-tools process (#24889) `Math.min(250, deadline - Date.now())` could return a negative value if the deadline expired between the while-condition check and the setTimeout call. Wrap with `Math.max(0, ...)` to ensure the sleep is never negative. Co-authored-by: Claude Sonnet 4.6 --- src/agents/bash-tools.process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 25248bf2218..028f56bbb75 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -331,7 +331,7 @@ export function createProcessTool( const deadline = Date.now() + pollWaitMs; while (!scopedSession.exited && Date.now() < deadline) { await new Promise((resolve) => - setTimeout(resolve, Math.min(250, deadline - Date.now())), + setTimeout(resolve, Math.max(0, Math.min(250, deadline - Date.now()))), ); } } From 237b9be937c8975c39b16814935af8eb0065b6bc Mon Sep 17 00:00:00 2001 From: Ali Al Jufairi <20195330@stu.uob.edu.bh> Date: Tue, 24 Feb 2026 12:23:01 +0900 Subject: [PATCH 192/314] chore(docs) : remove the mention of Anthropic OAuth since it is not allowed according to there new guidlines (#24989) --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 3cc1bacfc3f..7387372192f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin **Subscriptions (OAuth):** -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex) Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). From 9df80b73e26e23114b57e11b7532bfb05d450ec3 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 10:47:11 +0800 Subject: [PATCH 193/314] fix: allow RFC2544 benchmark range (198.18.0.0/15) through SSRF filter Telegram's API and file servers resolve to IPs in the 198.18.0.0/15 range (RFC 2544 benchmarking range). The SSRF filter was blocking these addresses because ipaddr.js classifies them as 'reserved', and the filter also had an explicit RFC2544_BENCHMARK_PREFIX check that blocked them unconditionally. Fix: exempt 198.18.0.0/15 from the 'reserved' range block in isBlockedSpecialUseIpv4Address(). Other 'reserved' ranges (TEST-NET-2, TEST-NET-3, documentation prefixes) remain blocked. The explicit RFC2544_BENCHMARK_PREFIX check is repurposed as the exemption guard. Closes #24973 --- src/infra/net/fetch-guard.ssrf.test.ts | 16 +++++++++++++++- src/infra/net/ssrf.pinning.test.ts | 10 +++++++++- src/infra/net/ssrf.test.ts | 17 +++++++++++------ src/shared/net/ip.ts | 16 +++++++++++++--- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index de0140d76a2..7d5b4090a6a 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -37,13 +37,27 @@ describe("fetchWithSsrFGuard hardening", () => { const fetchImpl = vi.fn(); await expect( fetchWithSsrFGuard({ - url: "http://198.18.0.1:8080/internal", + url: "http://198.51.100.1:8080/internal", fetchImpl, }), ).rejects.toThrow(/private|internal|blocked/i); expect(fetchImpl).not.toHaveBeenCalled(); }); + it("allows RFC2544 benchmark range IPv4 literal URLs (Telegram)", async () => { + const lookupFn = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); + // Should not throw — 198.18.x.x is allowed now + const result = await fetchWithSsrFGuard({ + url: "http://198.18.0.153/file", + fetchImpl, + lookupFn, + }); + expect(result.response.status).toBe(200); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 660b8b6df6b..7ae0242c054 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -51,12 +51,20 @@ describe("ssrf pinning", () => { it.each([ { name: "RFC1918 private address", address: "10.0.0.8" }, - { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, + { name: "TEST-NET-2 reserved range", address: "198.51.100.1" }, ])("rejects blocked DNS results: $name", async ({ address }) => { const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); + it("allows RFC2544 benchmark range addresses (used by Telegram)", async () => { + const lookup = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + const pinned = await resolvePinnedHostname("api.telegram.org", lookup); + expect(pinned.addresses).toContain("198.18.0.153"); + }); + it("falls back for non-matching hostnames", async () => { const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => { const cb = typeof options === "function" ? options : (callback as () => void); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 5d8fe8f6620..1bb2d77dbd5 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,8 +3,6 @@ import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ - "198.18.0.1", - "198.19.255.254", "198.51.100.42", "203.0.113.10", "192.0.0.8", @@ -15,7 +13,6 @@ const privateIpCases = [ "240.0.0.1", "255.255.255.255", "::ffff:127.0.0.1", - "::ffff:198.18.0.1", "64:ff9b::198.51.100.42", "0:0:0:0:0:ffff:7f00:1", "0000:0000:0000:0000:0000:ffff:7f00:0001", @@ -32,7 +29,6 @@ const privateIpCases = [ "2002:a9fe:a9fe::", "2001:0000:0:0:0:0:80ff:fefe", "2001:0000:0:0:0:0:3f57:fefe", - "2002:c612:0001::", "::", "::1", "fe80::1%lo0", @@ -45,13 +41,18 @@ const privateIpCases = [ const publicIpCases = [ "93.184.216.34", "198.17.255.255", + "198.18.0.1", + "198.18.0.153", + "198.19.255.254", "198.20.0.1", + "2002:c612:0001::", "198.51.99.1", "198.51.101.1", "203.0.112.1", "203.0.114.1", "223.255.255.255", "2606:4700:4700::1111", + "::ffff:198.18.0.1", "2001:db8::1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", @@ -119,9 +120,13 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); - it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { - expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + it("allows RFC2544 benchmark range (used by Telegram) but blocks adjacent special-use ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(false); + expect(isBlockedHostnameOrIp("198.18.0.153")).toBe(false); + expect(isBlockedHostnameOrIp("198.19.255.254")).toBe(false); expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1")).toBe(true); + expect(isBlockedHostnameOrIp("203.0.113.1")).toBe(true); }); it("blocks legacy IPv4 literal representations", () => { diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 21770a20e29..a6b84ddd09e 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -28,6 +28,12 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "linkLocal", "uniqueLocal", ]); +/** + * RFC 2544 benchmark range (198.18.0.0/15). Originally reserved for network + * device benchmarking, but in practice used by real services — notably + * Telegram's API/file servers resolve to addresses in this block. We + * therefore exempt it from the SSRF block list. + */ const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ @@ -248,9 +254,13 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { } export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - return ( - BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || address.match(RFC2544_BENCHMARK_PREFIX) - ); + const range = address.range(); + if (range === "reserved" && address.match(RFC2544_BENCHMARK_PREFIX)) { + // 198.18.0.0/15 is classified as "reserved" by ipaddr.js but is used by + // real public services (e.g. Telegram API). Allow it through. + return false; + } + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(range); } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { From 3af9d1f8e912873c60dfe34c265c4119b4ab9e68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:27:40 +0000 Subject: [PATCH 194/314] fix: scope Telegram RFC2544 SSRF exception to policy opt-in (#24982) (thanks @stakeswky) --- CHANGELOG.md | 1 + src/infra/net/fetch-guard.ssrf.test.ts | 10 ++---- src/infra/net/ssrf.pinning.test.ts | 13 +++++-- src/infra/net/ssrf.test.ts | 25 ++++++++------ src/infra/net/ssrf.ts | 34 +++++++++++++------ src/shared/net/ip.ts | 22 ++++++------ .../bot/delivery.resolve-media-retry.test.ts | 6 ++++ src/telegram/bot/delivery.ts | 4 +++ 8 files changed, 72 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a732763f33..7a7cc756076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 7d5b4090a6a..a03afba325f 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -37,23 +37,19 @@ describe("fetchWithSsrFGuard hardening", () => { const fetchImpl = vi.fn(); await expect( fetchWithSsrFGuard({ - url: "http://198.51.100.1:8080/internal", + url: "http://198.18.0.1:8080/internal", fetchImpl, }), ).rejects.toThrow(/private|internal|blocked/i); expect(fetchImpl).not.toHaveBeenCalled(); }); - it("allows RFC2544 benchmark range IPv4 literal URLs (Telegram)", async () => { - const lookupFn = vi.fn(async () => [ - { address: "198.18.0.153", family: 4 }, - ]) as unknown as LookupFn; + it("allows RFC2544 benchmark range IPv4 literal URLs when explicitly opted in", async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); - // Should not throw — 198.18.x.x is allowed now const result = await fetchWithSsrFGuard({ url: "http://198.18.0.153/file", fetchImpl, - lookupFn, + policy: { allowRfc2544BenchmarkRange: true }, }); expect(result.response.status).toBe(200); }); diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 7ae0242c054..19d61bdaee8 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -51,17 +51,26 @@ describe("ssrf pinning", () => { it.each([ { name: "RFC1918 private address", address: "10.0.0.8" }, + { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, { name: "TEST-NET-2 reserved range", address: "198.51.100.1" }, ])("rejects blocked DNS results: $name", async ({ address }) => { const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); - it("allows RFC2544 benchmark range addresses (used by Telegram)", async () => { + it("allows RFC2544 benchmark range addresses only when policy explicitly opts in", async () => { const lookup = vi.fn(async () => [ { address: "198.18.0.153", family: 4 }, ]) as unknown as LookupFn; - const pinned = await resolvePinnedHostname("api.telegram.org", lookup); + + await expect(resolvePinnedHostname("api.telegram.org", lookup)).rejects.toThrow( + /private|internal/i, + ); + + const pinned = await resolvePinnedHostnameWithPolicy("api.telegram.org", { + lookupFn: lookup, + policy: { allowRfc2544BenchmarkRange: true }, + }); expect(pinned.addresses).toContain("198.18.0.153"); }); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 1bb2d77dbd5..5826669196d 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,6 +3,8 @@ import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ + "198.18.0.1", + "198.19.255.254", "198.51.100.42", "203.0.113.10", "192.0.0.8", @@ -13,6 +15,7 @@ const privateIpCases = [ "240.0.0.1", "255.255.255.255", "::ffff:127.0.0.1", + "::ffff:198.18.0.1", "64:ff9b::198.51.100.42", "0:0:0:0:0:ffff:7f00:1", "0000:0000:0000:0000:0000:ffff:7f00:0001", @@ -29,6 +32,7 @@ const privateIpCases = [ "2002:a9fe:a9fe::", "2001:0000:0:0:0:0:80ff:fefe", "2001:0000:0:0:0:0:3f57:fefe", + "2002:c612:0001::", "::", "::1", "fe80::1%lo0", @@ -41,18 +45,13 @@ const privateIpCases = [ const publicIpCases = [ "93.184.216.34", "198.17.255.255", - "198.18.0.1", - "198.18.0.153", - "198.19.255.254", "198.20.0.1", - "2002:c612:0001::", "198.51.99.1", "198.51.101.1", "203.0.112.1", "203.0.114.1", "223.255.255.255", "2606:4700:4700::1111", - "::ffff:198.18.0.1", "2001:db8::1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", @@ -120,13 +119,17 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); - it("allows RFC2544 benchmark range (used by Telegram) but blocks adjacent special-use ranges", () => { - expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(false); - expect(isBlockedHostnameOrIp("198.18.0.153")).toBe(false); - expect(isBlockedHostnameOrIp("198.19.255.254")).toBe(false); + it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); - expect(isBlockedHostnameOrIp("198.51.100.1")).toBe(true); - expect(isBlockedHostnameOrIp("203.0.113.1")).toBe(true); + }); + + it("supports opt-in policy to allow RFC2544 benchmark range", () => { + const policy = { allowRfc2544BenchmarkRange: true }; + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("::ffff:198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1", policy)).toBe(true); }); it("blocks legacy IPv4 literal representations", () => { diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 3a4456e7839..2e4c69210d6 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -5,6 +5,7 @@ import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, isCanonicalDottedDecimalIPv4, + type Ipv4SpecialUseBlockOptions, isIpv4Address, isLegacyIpv4Literal, isPrivateOrLoopbackIpAddress, @@ -31,6 +32,7 @@ export type LookupFn = typeof dnsLookup; export type SsrFPolicy = { allowPrivateNetwork?: boolean; dangerouslyAllowPrivateNetwork?: boolean; + allowRfc2544BenchmarkRange?: boolean; allowedHostnames?: string[]; hostnameAllowlist?: string[]; }; @@ -65,6 +67,12 @@ function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean { return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; } +function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { + return { + allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, + }; +} + function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { if (pattern.startsWith("*.")) { const suffix = pattern.slice(2); @@ -97,7 +105,7 @@ function looksLikeUnsupportedIpv4Literal(address: string): boolean { } // Returns true for private/internal and special-use non-global addresses. -export function isPrivateIpAddress(address: string): boolean { +export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolean { let normalized = address.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { normalized = normalized.slice(1, -1); @@ -105,18 +113,19 @@ export function isPrivateIpAddress(address: string): boolean { if (!normalized) { return false; } + const blockOptions = resolveIpv4SpecialUseBlockOptions(policy); const strictIp = parseCanonicalIpAddress(normalized); if (strictIp) { if (isIpv4Address(strictIp)) { - return isBlockedSpecialUseIpv4Address(strictIp); + return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); } if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { return true; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); if (embeddedIpv4) { - return isBlockedSpecialUseIpv4Address(embeddedIpv4); + return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions); } return false; } @@ -154,27 +163,30 @@ function isBlockedHostnameNormalized(normalized: string): boolean { ); } -export function isBlockedHostnameOrIp(hostname: string): boolean { +export function isBlockedHostnameOrIp(hostname: string, policy?: SsrFPolicy): boolean { const normalized = normalizeHostname(hostname); if (!normalized) { return false; } - return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized); + return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy); } const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address"; const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address"; -function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void { - if (isBlockedHostnameOrIp(hostnameOrIp)) { +function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy): void { + if (isBlockedHostnameOrIp(hostnameOrIp, policy)) { throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE); } } -function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void { +function assertAllowedResolvedAddressesOrThrow( + results: readonly LookupAddress[], + policy?: SsrFPolicy, +): void { for (const entry of results) { // Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift. - if (isBlockedHostnameOrIp(entry.address)) { + if (isBlockedHostnameOrIp(entry.address, policy)) { throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE); } } @@ -264,7 +276,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects. - assertAllowedHostOrIpOrThrow(normalized); + assertAllowedHostOrIpOrThrow(normalized, params.policy); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -275,7 +287,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets. - assertAllowedResolvedAddressesOrThrow(results); + assertAllowedResolvedAddressesOrThrow(results, params.policy); } const addresses = Array.from(new Set(results.map((entry) => entry.address))); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index a6b84ddd09e..2342bdedafe 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -28,13 +28,10 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "linkLocal", "uniqueLocal", ]); -/** - * RFC 2544 benchmark range (198.18.0.0/15). Originally reserved for network - * device benchmarking, but in practice used by real services — notably - * Telegram's API/file servers resolve to addresses in this block. We - * therefore exempt it from the SSRF block list. - */ const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; +export type Ipv4SpecialUseBlockOptions = { + allowRfc2544BenchmarkRange?: boolean; +}; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ matches: (parts: number[]) => boolean; @@ -253,14 +250,15 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { return parsed.range() === "carrierGradeNat"; } -export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - const range = address.range(); - if (range === "reserved" && address.match(RFC2544_BENCHMARK_PREFIX)) { - // 198.18.0.0/15 is classified as "reserved" by ipaddr.js but is used by - // real public services (e.g. Telegram API). Allow it through. +export function isBlockedSpecialUseIpv4Address( + address: ipaddr.IPv4, + options: Ipv4SpecialUseBlockOptions = {}, +): boolean { + const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX); + if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) { return false; } - return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(range); + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange; } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 2c54396a834..2becbcd93e9 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -92,6 +92,12 @@ async function expectTransientGetFileRetrySuccess() { await flushRetryTimers(); const result = await promise; expect(getFile).toHaveBeenCalledTimes(2); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); return result; } diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 945cd2c2557..a20bf045610 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -35,6 +35,9 @@ import type { StickerMetadata, TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; +const TELEGRAM_MEDIA_SSRF_POLICY = { + allowRfc2544BenchmarkRange: true, +} as const; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -320,6 +323,7 @@ export async function resolveMedia( fetchImpl, filePathHint: filePath, maxBytes, + ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, }); const originalName = fetched.fileName ?? filePath; return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName); From ae281a6f61e3b4566ff79893506385389f81c9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E5=81=9A=E4=BA=86=E7=9D=A1=E5=A4=A7=E8=A7=89?= <64798754+stakeswky@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:33:17 +0800 Subject: [PATCH 195/314] fix: suppress "Run doctor --fix" hint when already in fix mode with no changes (#24666) When running `openclaw doctor --fix` and no config changes are needed, the else branch unconditionally showed "Run doctor --fix to apply changes" which is confusing since we just ran --fix. Now the hint only appears when NOT in fix mode (i.e. when running plain `openclaw doctor`). When in fix mode with nothing to change, the command silently proceeds to the "Doctor complete." outro. Fixes #24566 Co-authored-by: User --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 714a3d2574f..4aa0241da19 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -301,7 +301,7 @@ export async function doctorCommand( if (fs.existsSync(backupPath)) { runtime.log(`Backup: ${shortenHomePath(backupPath)}`); } - } else { + } else if (!prompter.shouldRepair) { runtime.log(`Run "${formatCliCommand("openclaw doctor --fix")}" to apply changes.`); } From 1e23d2ecea005daaff14ebe6b08fcdc56a75c406 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:33:21 -0400 Subject: [PATCH 196/314] fix(whatsapp): respect selfChatMode config in access-control (#24738) The selfChatMode config field was resolved by accounts.ts but never consumed in the access-control logic. Use nullish coalescing so an explicit true/false from config takes precedence over the allowFrom heuristic, while undefined falls back to the existing behavior. Fixes #23788 Co-authored-by: Claude --- src/web/inbound/access-control.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 794897a5388..2e759507cb9 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -75,7 +75,7 @@ export async function checkInboundAccessControl(params: { account.groupAllowFrom ?? (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); const isSamePhone = params.from === params.selfE164; - const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom); + const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom); const pairingGraceMs = typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0 ? params.pairingGraceMs From a3b82a563dfbb963e552b54463dc0578060d1458 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:33:24 -0400 Subject: [PATCH 197/314] fix: resolve symlinks in pnpm/bun global install detection (#24744) Use tryRealpath() instead of path.resolve() when comparing expected package paths in detectGlobalInstallManagerForRoot(). path.resolve() only normalizes path strings without following symlinks, causing pnpm global installs to go undetected since pnpm symlinks node_modules entries into its .pnpm content-addressable store. Fixes #22768 Co-authored-by: Claude Opus 4.6 --- src/infra/update-global.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index a678934b409..e85949f3cab 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -84,7 +84,8 @@ export async function detectGlobalInstallManagerForRoot( const globalReal = await tryRealpath(globalRoot); for (const name of ALL_PACKAGE_NAMES) { const expected = path.join(globalReal, name); - if (path.resolve(expected) === path.resolve(pkgReal)) { + const expectedReal = await tryRealpath(expected); + if (path.resolve(expectedReal) === path.resolve(pkgReal)) { return manager; } } @@ -94,7 +95,8 @@ export async function detectGlobalInstallManagerForRoot( const bunGlobalReal = await tryRealpath(bunGlobalRoot); for (const name of ALL_PACKAGE_NAMES) { const bunExpected = path.join(bunGlobalReal, name); - if (path.resolve(bunExpected) === path.resolve(pkgReal)) { + const bunExpectedReal = await tryRealpath(bunExpected); + if (path.resolve(bunExpectedReal) === path.resolve(pkgReal)) { return "bun"; } } From 04bcabcbae66b6c322f192e1265d817b14c290cf Mon Sep 17 00:00:00 2001 From: junwon <153147718+junjunjunbong@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:33:27 +0900 Subject: [PATCH 198/314] fix(infra): handle Windows dev=0 in sameFileIdentity TOCTOU check (#24939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(infra): handle Windows dev=0 in sameFileIdentity TOCTOU check On Windows, `fs.lstatSync` (path-based) returns `dev: 0` while `fs.fstatSync` (fd-based) returns the real NTFS volume serial number. This mismatch caused `sameFileIdentity` to always fail, making `openVerifiedFileSync` reject every file — silently breaking all Control UI static file serving (HTTP 404). Fall back to ino-only comparison when either dev is 0 on Windows. ino remains unique within a single volume, so TOCTOU protection is preserved. Fixes #24692 * fix: format sameFileIdentity wrapping (#24939) --------- Co-authored-by: Peter Steinberger --- src/infra/safe-open-sync.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/infra/safe-open-sync.ts b/src/infra/safe-open-sync.ts index ac4638483b3..f2dbdfb703b 100644 --- a/src/infra/safe-open-sync.ts +++ b/src/infra/safe-open-sync.ts @@ -17,7 +17,12 @@ function isExpectedPathError(error: unknown): boolean { } export function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; + // On Windows, lstatSync (by path) may return dev=0 while fstatSync (by fd) + // returns the real volume serial number. When either dev is 0, fall back to + // ino-only comparison which is still unique within a single volume. + const devMatch = + left.dev === right.dev || (process.platform === "win32" && (left.dev === 0 || right.dev === 0)); + return devMatch && left.ino === right.ino; } export function openVerifiedFileSync(params: { From 52ac7634dbb93c647144afcae8f2d6db3e855513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Tue, 24 Feb 2026 11:33:30 +0800 Subject: [PATCH 199/314] fix: persist reasoningLevel 'off' instead of deleting it (#24406) (#24559) When a user runs /reasoning off, the session patch handler deleted the reasoningLevel field from the session entry. This caused get-reply-directives to treat reasoning as 'not explicitly set', which triggered resolveDefaultReasoningLevel() to re-enable reasoning for capable models (e.g. Claude Opus). The fix persists 'off' explicitly, matching how directive-handling.persist.ts already handles the inline /reasoning off command. Fixes #24406 Fixes #24411 Co-authored-by: echoVic --- src/gateway/sessions-patch.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 99e83a3bea0..d55cf2cf1a4 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -186,11 +186,9 @@ export async function applySessionsPatchToStore(params: { if (!normalized) { return invalid('invalid reasoningLevel (use "on"|"off"|"stream")'); } - if (normalized === "off") { - delete next.reasoningLevel; - } else { - next.reasoningLevel = normalized; - } + // Persist "off" explicitly so that resolveDefaultReasoningLevel() + // does not re-enable reasoning for capable models (#24406). + next.reasoningLevel = normalized; } } From e3da57d956a848c00aa07667632c1a76b0c9f42a Mon Sep 17 00:00:00 2001 From: banna-commits Date: Tue, 24 Feb 2026 04:33:34 +0100 Subject: [PATCH 200/314] fix: add exponential backoff to announce queue drain on failure (#24783) When the gateway rejects connections (e.g. scope-upgrade 'pairing required'), the announce queue drain loop would retry every ~1s indefinitely because the only delay was the fixed debounceMs (default 1000ms). This adds a consecutiveFailures counter with exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s (capped). The counter resets on successful drain. The backoff is applied by shifting lastEnqueuedAt forward so that waitForQueueDebounce naturally delays the next attempt. Fixes #24777 Co-authored-by: Knut --- src/agents/subagent-announce-queue.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index c81dd94b1d9..611541c186e 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -48,6 +48,8 @@ type AnnounceQueueState = { droppedCount: number; summaryLines: string[]; send: (item: AnnounceQueueItem) => Promise; + /** Consecutive drain failures — drives exponential backoff on errors. */ + consecutiveFailures: number; }; const ANNOUNCE_QUEUES = new Map(); @@ -89,6 +91,7 @@ function getAnnounceQueue( droppedCount: 0, summaryLines: [], send, + consecutiveFailures: 0, }; applyQueueRuntimeSettings({ target: created, @@ -174,10 +177,16 @@ function scheduleAnnounceDrain(key: string) { break; } } + // Drain succeeded — reset failure counter. + queue.consecutiveFailures = 0; } catch (err) { - // Keep items in queue and retry after debounce; avoid hot-loop retries. - queue.lastEnqueuedAt = Date.now(); - defaultRuntime.error?.(`announce queue drain failed for ${key}: ${String(err)}`); + queue.consecutiveFailures++; + // Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s. + const errorBackoffMs = Math.min(1000 * Math.pow(2, queue.consecutiveFailures), 60_000); + queue.lastEnqueuedAt = Date.now() + errorBackoffMs - queue.debounceMs; + defaultRuntime.error?.( + `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(errorBackoffMs / 1000)}s): ${String(err)}`, + ); } finally { queue.draining = false; if (queue.items.length === 0 && queue.droppedCount === 0) { From c1fe688d40d57ce513d633d8a7682383ddc6fdef Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:37 +0800 Subject: [PATCH 201/314] fix(gateway): safely extract text from content arrays in prompt builder (#24946) * fix(gateway): safely extract text from message content arrays in prompt builder When HistoryEntry.body is a content array (e.g. [{type:"text", text:"hello"}]) rather than a plain string, template literal interpolation produces "[object Object]" instead of the actual message text. This affects users whose session messages were stored with array content format. Add a safeBody helper that detects non-string body values and uses extractTextFromChatContent to extract the text, preventing the [object Object] serialization in both the current-message return path and the history formatting path. Fixes openclaw#24688 Co-authored-by: Cursor * fix: format gateway agent prompt helper (#24946) --------- Co-authored-by: Cursor Co-authored-by: Peter Steinberger --- src/gateway/agent-prompt.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/gateway/agent-prompt.ts b/src/gateway/agent-prompt.ts index 58e12bacd02..5904726b927 100644 --- a/src/gateway/agent-prompt.ts +++ b/src/gateway/agent-prompt.ts @@ -1,10 +1,23 @@ import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; export type ConversationEntry = { role: "user" | "assistant" | "tool"; entry: HistoryEntry; }; +/** + * Coerce body to string. Handles cases where body is a content array + * (e.g. [{type:"text", text:"hello"}]) that would serialize as + * [object Object] if used directly in a template literal. + */ +function safeBody(body: unknown): string { + if (typeof body === "string") { + return body; + } + return extractTextFromChatContent(body) ?? ""; +} + export function buildAgentMessageFromConversationEntries(entries: ConversationEntry[]): string { if (entries.length === 0) { return ""; @@ -31,10 +44,10 @@ export function buildAgentMessageFromConversationEntries(entries: ConversationEn const historyEntries = entries.slice(0, currentIndex).map((e) => e.entry); if (historyEntries.length === 0) { - return currentEntry.body; + return safeBody(currentEntry.body); } - const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; + const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${safeBody(entry.body)}`; return buildHistoryContextFromEntries({ entries: [...historyEntries, currentEntry], currentMessage: formatEntry(currentEntry), From 38da3f40cb6b5f1b404f16c5a79c8a547a3e48f0 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:40 +0800 Subject: [PATCH 202/314] fix(discord): suppress reasoning/thinking block payloads from delivery (#24969) Block payloads (info.kind === "block") contain reasoning/thinking content that should only be visible in the internal web UI. When streamMode is "partial", these blocks were being delivered to Discord as visible messages, leaking chain-of-thought to end users. Add an early return for block payloads in the deliver callback, consistent with the WhatsApp fix and Telegram's existing behavior. Fixes #24532 Co-authored-by: Cursor --- src/discord/monitor/message-handler.process.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 2d5b4058f6e..1c41fef76ec 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -557,6 +557,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) humanDelay: resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload, info) => { const isFinal = info.kind === "final"; + if (info.kind === "block") { + // Block payloads carry reasoning/thinking content that should not be + // delivered to external channels. Skip them regardless of streamMode. + return; + } if (draftStream && isFinal) { await flushDraft(); const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; From 9ced64054f2b1de5ee6b2189faaf778febcb2ed4 Mon Sep 17 00:00:00 2001 From: Peter Machona Date: Tue, 24 Feb 2026 03:33:44 +0000 Subject: [PATCH 203/314] fix(auth): classify missing OAuth scopes as auth failures (#24761) --- src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts | 4 ++++ src/agents/pi-embedded-helpers/errors.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index f4ae781e8c3..278c2d30bcb 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -393,6 +393,10 @@ describe("classifyFailoverReason", () => { expect(classifyFailoverReason("invalid api key")).toBe("auth"); expect(classifyFailoverReason("no credentials found")).toBe("auth"); expect(classifyFailoverReason("no api key found")).toBe("auth"); + expect(classifyFailoverReason("You have insufficient permissions for this operation.")).toBe( + "auth", + ); + expect(classifyFailoverReason("Missing scopes: model.request")).toBe("auth"); expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); expect(classifyFailoverReason("resource has been exhausted")).toBe("rate_limit"); expect( diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 80ba2219868..e0c7bf4c801 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -655,6 +655,9 @@ const ERROR_PATTERNS = { "unauthorized", "forbidden", "access denied", + "insufficient permissions", + "insufficient permission", + /missing scopes?:/i, "expired", "token has expired", /\b401\b/, From f5cab29ec745d6ec806f6b44434bea5b6ee91e0c Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:47 +0800 Subject: [PATCH 204/314] fix(synology-chat): deregister stale webhook route before re-registering on restart (#24971) When the Synology Chat plugin restarts (auto-restart or health monitor), startAccount is called again without calling the previous stop(). The HTTP route is still registered, so registerPluginHttpRoute returns a no-op unregister function and logs "already registered". This triggers another restart, creating an infinite loop. Store the unregister function at module level keyed by account+path. Before registering, check for and call any stale unregister from the previous start cycle, ensuring a clean slate for route registration. Fixes #24894 Co-authored-by: Cursor --- extensions/synology-chat/src/channel.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 0e205f60c3e..37d4a4216ba 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -20,6 +20,8 @@ import { createWebhookHandler } from "./webhook-handler.js"; const CHANNEL_ID = "synology-chat"; const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough()); +const activeRouteUnregisters = new Map void>(); + export function createSynologyChatPlugin() { return { id: CHANNEL_ID, @@ -270,7 +272,16 @@ export function createSynologyChatPlugin() { log, }); - // Register HTTP route via the SDK + // Deregister any stale route from a previous start (e.g. on auto-restart) + // to avoid "already registered" collisions that trigger infinite loops. + const routeKey = `${accountId}:${account.webhookPath}`; + const prevUnregister = activeRouteUnregisters.get(routeKey); + if (prevUnregister) { + log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`); + prevUnregister(); + activeRouteUnregisters.delete(routeKey); + } + const unregister = registerPluginHttpRoute({ path: account.webhookPath, pluginId: CHANNEL_ID, @@ -278,6 +289,7 @@ export function createSynologyChatPlugin() { log: (msg: string) => log?.info?.(msg), handler, }); + activeRouteUnregisters.set(routeKey, unregister); log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`); @@ -285,6 +297,7 @@ export function createSynologyChatPlugin() { stop: () => { log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`); if (typeof unregister === "function") unregister(); + activeRouteUnregisters.delete(routeKey); }, }; }, From bf91b347c1a2d49827ed79cf6e91248a07e6e909 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 11:33:51 +0800 Subject: [PATCH 205/314] fix(plugins): use manifest id as config entry key instead of npm package name (#24796) * fix(plugins): use manifest id as config key instead of npm package name Plugin manifests (openclaw.plugin.json) define a canonical 'id' field that is used as the authoritative plugin identifier by the manifest registry. However, the install command was deriving the config entry key from the npm package name (e.g. 'cognee-openclaw') rather than the manifest id (e.g. 'memory-cognee'), causing a latent mismatch. On the next gateway reload the plugin could not be found under the config key derived from the npm package name, causing 'plugin not found' errors and potentially shutting the gateway down. Fix: after extracting the package directory, read openclaw.plugin.json and prefer its 'id' field over the npm package name when registering the config entry. Falls back to the npm-derived id if the manifest file is absent or has no valid id. A diagnostic info message is emitted when the two values differ so the mismatch is visible in the install log. The update path (src/plugins/update.ts) already correctly reads the manifest id and is unaffected. Fixes #24429 * fix: format plugin install manifest-id path (#24796) --------- Co-authored-by: Peter Steinberger --- src/plugins/install.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 40aeb3c5a63..49ce72dcd07 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -26,6 +26,7 @@ import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { loadPluginManifest } from "./manifest.js"; type PluginInstallLogger = { info?: (message: string) => void; @@ -149,7 +150,17 @@ async function installPluginFromPackageDir(params: { } const pkgName = typeof manifest.name === "string" ? manifest.name : ""; - const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + + // Prefer the canonical `id` from openclaw.plugin.json over the npm package name. + // This avoids a latent key-mismatch bug: if the manifest id (e.g. "memory-cognee") + // differs from the npm package name (e.g. "cognee-openclaw"), the plugin registry + // uses the manifest id as the authoritative key, so the config entry must match it. + const ocManifestResult = loadPluginManifest(params.packageDir); + const manifestPluginId = + ocManifestResult.ok && ocManifestResult.manifest.id ? ocManifestResult.manifest.id : undefined; + + const pluginId = manifestPluginId ?? npmPluginId; const pluginIdError = validatePluginId(pluginId); if (pluginIdError) { return { ok: false, error: pluginIdError }; @@ -161,6 +172,12 @@ async function installPluginFromPackageDir(params: { }; } + if (manifestPluginId && manifestPluginId !== npmPluginId) { + logger.info?.( + `Plugin manifest id "${manifestPluginId}" differs from npm package name "${npmPluginId}"; using manifest id as the config key.`, + ); + } + const packageDir = path.resolve(params.packageDir); const forcedScanEntries: string[] = []; for (const entry of extensions) { From d95ee859f88008ac7570c37a2eefd94d960e4a09 Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 24 Feb 2026 11:33:54 +0800 Subject: [PATCH 206/314] fix(cron): use full prompt mode for isolated cron sessions to include skills (#24944) Isolated cron sessions (agentTurn) were grouped with subagent sessions under the "minimal" prompt mode, which causes buildSkillsSection to return an empty array. This meant was never included in the system prompt for isolated cron runs. Subagent sessions legitimately need minimal prompts (reduced context), but isolated cron sessions are full agent turns that should have access to all configured skills, matching the behavior of normal chat sessions and non-isolated cron runs. Remove isCronSessionKey from the minimal prompt condition so only subagent sessions use "minimal" mode. Fixes openclaw#24888 Co-authored-by: Cursor --- src/agents/pi-embedded-runner/run/attempt.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f811b2c4ff7..12d246e8a30 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -19,7 +19,7 @@ import type { PluginHookBeforeAgentStartResult, PluginHookBeforePromptBuildResult, } from "../../../plugins/types.js"; -import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; +import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; @@ -494,10 +494,7 @@ export async function runEmbeddedAttempt( }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = - isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) - ? "minimal" - : "full"; + const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], From 0bdcca2f350978843d5553fb26f0a753e908129c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:34:31 +0000 Subject: [PATCH 207/314] test(whatsapp): add log redaction coverage --- CHANGELOG.md | 1 + src/web/auto-reply/heartbeat-runner.test.ts | 36 ++++++++++++++++++--- src/web/outbound.test.ts | 30 +++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7cc756076..079ad76cb82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/web/auto-reply/heartbeat-runner.test.ts b/src/web/auto-reply/heartbeat-runner.test.ts index 78014787ad3..87d8d8a7ca9 100644 --- a/src/web/auto-reply/heartbeat-runner.test.ts +++ b/src/web/auto-reply/heartbeat-runner.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { getReplyFromConfig } from "../../auto-reply/reply.js"; import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import type { sendMessageWhatsApp } from "../outbound.js"; const state = vi.hoisted(() => ({ @@ -15,6 +16,10 @@ const state = vi.hoisted(() => ({ idleExpiresAt: null as number | null, }, events: [] as unknown[], + loggerInfoCalls: [] as unknown[][], + loggerWarnCalls: [] as unknown[][], + heartbeatInfoLogs: [] as string[], + heartbeatWarnLogs: [] as string[], })); vi.mock("../../agents/current-time.js", () => ({ @@ -64,15 +69,15 @@ vi.mock("../../infra/heartbeat-events.js", () => ({ vi.mock("../../logging.js", () => ({ getChildLogger: () => ({ - info: () => {}, - warn: () => {}, + info: (...args: unknown[]) => state.loggerInfoCalls.push(args), + warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), }), })); vi.mock("./loggers.js", () => ({ whatsappHeartbeatLog: { - info: () => {}, - warn: () => {}, + info: (msg: string) => state.heartbeatInfoLogs.push(msg), + warn: (msg: string) => state.heartbeatWarnLogs.push(msg), }, })); @@ -115,6 +120,10 @@ describe("runWebHeartbeatOnce", () => { idleExpiresAt: null, }; state.events = []; + state.loggerInfoCalls = []; + state.loggerWarnCalls = []; + state.heartbeatInfoLogs = []; + state.heartbeatWarnLogs = []; senderMock = vi.fn(async () => ({ messageId: "m1" })); sender = senderMock as unknown as typeof sendMessageWhatsApp; @@ -187,4 +196,23 @@ describe("runWebHeartbeatOnce", () => { ]), ); }); + + it("redacts recipient and omits body preview in heartbeat logs", async () => { + replyResolverMock.mockResolvedValue({ text: "sensitive heartbeat body" }); + const { runWebHeartbeatOnce } = await getModules(); + await runWebHeartbeatOnce(buildRunArgs({ dryRun: true })); + + const expected = redactIdentifier("+123"); + const heartbeatLogs = state.heartbeatInfoLogs.join("\n"); + const childLoggerLogs = state.loggerInfoCalls.map((entry) => JSON.stringify(entry)).join("\n"); + + expect(heartbeatLogs).toContain(expected); + expect(heartbeatLogs).not.toContain("+123"); + expect(heartbeatLogs).not.toContain("sensitive heartbeat body"); + + expect(childLoggerLogs).toContain(expected); + expect(childLoggerLogs).not.toContain("+123"); + expect(childLoggerLogs).not.toContain("sensitive heartbeat body"); + expect(childLoggerLogs).not.toContain('"preview"'); + }); }); diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index 5f627b454ac..e60d15158fc 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -1,5 +1,10 @@ +import crypto from "node:crypto"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import { setActiveWebListener } from "./active-listener.js"; const loadWebMediaMock = vi.fn(); @@ -154,6 +159,31 @@ describe("web outbound", () => { }); }); + it("redacts recipients and poll text in outbound logs", async () => { + const logPath = path.join(os.tmpdir(), `openclaw-outbound-${crypto.randomUUID()}.log`); + setLoggerOverride({ level: "trace", file: logPath }); + + await sendPollWhatsApp( + "+1555", + { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 1 }, + { verbose: false }, + ); + + await vi.waitFor( + () => { + expect(fsSync.existsSync(logPath)).toBe(true); + }, + { timeout: 2_000, interval: 5 }, + ); + + const content = fsSync.readFileSync(logPath, "utf-8"); + expect(content).toContain(redactIdentifier("+1555")); + expect(content).toContain(redactIdentifier("1555@s.whatsapp.net")); + expect(content).not.toContain(`"to":"+1555"`); + expect(content).not.toContain(`"jid":"1555@s.whatsapp.net"`); + expect(content).not.toContain("Lunch?"); + }); + it("sends reactions via active listener", async () => { await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { verbose: false, From aef45b2abb681f85bf902c1596411cfbfb8159b5 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:38:40 -0800 Subject: [PATCH 208/314] fix(logging): redact phone numbers and message content from WhatsApp logs Apply redactIdentifier() (SHA-256 hashing) to all recipient JIDs and phone numbers logged by sendMessageWhatsApp, sendReactionWhatsApp, sendPollWhatsApp, and runWebHeartbeatOnce. Remove poll question text and message preview content from log entries, replacing with character counts where useful for debugging. The existing redactIdentifier() utility in src/logging/redact-identifier.ts was already implemented but not wired into any WhatsApp logging path. This commit connects it to all affected call sites while leaving functional parameters (actual send calls, event emitters) untouched. Closes #24957 --- src/web/auto-reply/heartbeat-runner.ts | 48 +++++++++++++++----------- src/web/outbound.ts | 43 ++++++++++++----------- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/src/web/auto-reply/heartbeat-runner.ts b/src/web/auto-reply/heartbeat-runner.ts index 5b89c785c65..e393339a781 100644 --- a/src/web/auto-reply/heartbeat-runner.ts +++ b/src/web/auto-reply/heartbeat-runner.ts @@ -18,13 +18,13 @@ import { import { emitHeartbeatEvent, resolveIndicatorType } from "../../infra/heartbeat-events.js"; import { resolveHeartbeatVisibility } from "../../infra/heartbeat-visibility.js"; import { getChildLogger } from "../../logging.js"; +import { redactIdentifier } from "../../logging/redact-identifier.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { sendMessageWhatsApp } from "../outbound.js"; import { newConnectionId } from "../reconnect.js"; import { formatError } from "../session.js"; import { whatsappHeartbeatLog } from "./loggers.js"; import { getSessionSnapshot } from "./session-snapshot.js"; -import { elide } from "./util.js"; export async function runWebHeartbeatOnce(opts: { cfg?: ReturnType; @@ -40,10 +40,11 @@ export async function runWebHeartbeatOnce(opts: { const replyResolver = opts.replyResolver ?? getReplyFromConfig; const sender = opts.sender ?? sendMessageWhatsApp; const runId = newConnectionId(); + const redactedTo = redactIdentifier(to); const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId, - to, + to: redactedTo, }); const cfg = cfgOverride ?? loadConfig(); @@ -57,20 +58,20 @@ export async function runWebHeartbeatOnce(opts: { return false; } if (dryRun) { - whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${to}`); + whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`); return false; } const sendResult = await sender(to, heartbeatOkText, { verbose }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: heartbeatOkText.length, reason: "heartbeat-ok", }, "heartbeat ok sent", ); - whatsappHeartbeatLog.info(`heartbeat ok sent to ${to} (id ${sendResult.messageId})`); + whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`); return true; }; @@ -100,7 +101,7 @@ export async function runWebHeartbeatOnce(opts: { if (verbose) { heartbeatLogger.info( { - to, + to: redactedTo, sessionKey: sessionSnapshot.key, sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null, sessionFresh: sessionSnapshot.fresh, @@ -122,7 +123,7 @@ export async function runWebHeartbeatOnce(opts: { if (overrideBody) { if (dryRun) { whatsappHeartbeatLog.info( - `[dry-run] web send -> ${to}: ${elide(overrideBody.trim(), 200)} (manual message)`, + `[dry-run] web send -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`, ); return; } @@ -137,19 +138,21 @@ export async function runWebHeartbeatOnce(opts: { }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: overrideBody.length, reason: "manual-message", }, "manual heartbeat message sent", ); - whatsappHeartbeatLog.info(`manual heartbeat sent to ${to} (id ${sendResult.messageId})`); + whatsappHeartbeatLog.info( + `manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`, + ); return; } if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { - heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, @@ -181,7 +184,7 @@ export async function runWebHeartbeatOnce(opts: { ) { heartbeatLogger.info( { - to, + to: redactedTo, reason: "empty-reply", sessionId: sessionSnapshot.entry?.sessionId ?? null, }, @@ -226,7 +229,7 @@ export async function runWebHeartbeatOnce(opts: { } heartbeatLogger.info( - { to, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, + { to: redactedTo, reason: "heartbeat-token", rawLength: replyPayload.text?.length }, "heartbeat skipped", ); const okSent = await maybeSendHeartbeatOk(); @@ -241,14 +244,17 @@ export async function runWebHeartbeatOnce(opts: { } if (hasMedia) { - heartbeatLogger.warn({ to }, "heartbeat reply contained media; sending text only"); + heartbeatLogger.warn( + { to: redactedTo }, + "heartbeat reply contained media; sending text only", + ); } const finalText = stripped.text || replyPayload.text || ""; // Check if alerts are disabled for WhatsApp if (!visibility.showAlerts) { - heartbeatLogger.info({ to, reason: "alerts-disabled" }, "heartbeat skipped"); + heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped"); emitHeartbeatEvent({ status: "skipped", to, @@ -262,8 +268,11 @@ export async function runWebHeartbeatOnce(opts: { } if (dryRun) { - heartbeatLogger.info({ to, reason: "dry-run", chars: finalText.length }, "heartbeat dry-run"); - whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`); + heartbeatLogger.info( + { to: redactedTo, reason: "dry-run", chars: finalText.length }, + "heartbeat dry-run", + ); + whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`); return; } @@ -278,17 +287,16 @@ export async function runWebHeartbeatOnce(opts: { }); heartbeatLogger.info( { - to, + to: redactedTo, messageId: sendResult.messageId, chars: finalText.length, - preview: elide(finalText, 140), }, "heartbeat sent", ); - whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`); + whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`); } catch (err) { const reason = formatError(err); - heartbeatLogger.warn({ to, error: reason }, "heartbeat failed"); + heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed"); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); emitHeartbeatEvent({ status: "failed", diff --git a/src/web/outbound.ts b/src/web/outbound.ts index ce8b4466949..da1428a6980 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { generateSecureUuid } from "../infra/secure-random.js"; import { getChildLogger } from "../logging/logger.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { convertMarkdownTables } from "../markdown/tables.js"; import { markdownToWhatsApp } from "../markdown/whatsapp.js"; @@ -37,13 +38,15 @@ export async function sendMessageWhatsApp( }); text = convertMarkdownTables(text ?? "", tableMode); text = markdownToWhatsApp(text); + const redactedTo = redactIdentifier(to); const logger = getChildLogger({ module: "web-outbound", correlationId, - to, + to: redactedTo, }); try { const jid = toWhatsappJid(to); + const redactedJid = redactIdentifier(jid); let mediaBuffer: Buffer | undefined; let mediaType: string | undefined; let documentFileName: string | undefined; @@ -69,8 +72,8 @@ export async function sendMessageWhatsApp( documentFileName = media.fileName; } } - outboundLog.info(`Sending message -> ${jid}${options.mediaUrl ? " (media)" : ""}`); - logger.info({ jid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); + outboundLog.info(`Sending message -> ${redactedJid}${options.mediaUrl ? " (media)" : ""}`); + logger.info({ jid: redactedJid, hasMedia: Boolean(options.mediaUrl) }, "sending message"); await active.sendComposingTo(to); const hasExplicitAccountId = Boolean(options.accountId?.trim()); const accountId = hasExplicitAccountId ? resolvedAccountId : undefined; @@ -88,13 +91,13 @@ export async function sendMessageWhatsApp( const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const durationMs = Date.now() - startedAt; outboundLog.info( - `Sent message ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, + `Sent message ${messageId} -> ${redactedJid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`, ); - logger.info({ jid, messageId }, "sent message"); + logger.info({ jid: redactedJid, messageId }, "sent message"); return { messageId, toJid: jid }; } catch (err) { logger.error( - { err: String(err), to, hasMedia: Boolean(options.mediaUrl) }, + { err: String(err), to: redactedTo, hasMedia: Boolean(options.mediaUrl) }, "failed to send via web session", ); throw err; @@ -114,16 +117,18 @@ export async function sendReactionWhatsApp( ): Promise { const correlationId = generateSecureUuid(); const { listener: active } = requireActiveWebListener(options.accountId); + const redactedChatJid = redactIdentifier(chatJid); const logger = getChildLogger({ module: "web-outbound", correlationId, - chatJid, + chatJid: redactedChatJid, messageId, }); try { const jid = toWhatsappJid(chatJid); + const redactedJid = redactIdentifier(jid); outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`); - logger.info({ chatJid: jid, messageId, emoji }, "sending reaction"); + logger.info({ chatJid: redactedJid, messageId, emoji }, "sending reaction"); await active.sendReaction( chatJid, messageId, @@ -132,10 +137,10 @@ export async function sendReactionWhatsApp( options.participant, ); outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`); - logger.info({ chatJid: jid, messageId, emoji }, "sent reaction"); + logger.info({ chatJid: redactedJid, messageId, emoji }, "sent reaction"); } catch (err) { logger.error( - { err: String(err), chatJid, messageId, emoji }, + { err: String(err), chatJid: redactedChatJid, messageId, emoji }, "failed to send reaction via web session", ); throw err; @@ -150,19 +155,20 @@ export async function sendPollWhatsApp( const correlationId = generateSecureUuid(); const startedAt = Date.now(); const { listener: active } = requireActiveWebListener(options.accountId); + const redactedTo = redactIdentifier(to); const logger = getChildLogger({ module: "web-outbound", correlationId, - to, + to: redactedTo, }); try { const jid = toWhatsappJid(to); + const redactedJid = redactIdentifier(jid); const normalized = normalizePollInput(poll, { maxOptions: 12 }); - outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`); + outboundLog.info(`Sending poll -> ${redactedJid}`); logger.info( { - jid, - question: normalized.question, + jid: redactedJid, optionCount: normalized.options.length, maxSelections: normalized.maxSelections, }, @@ -171,14 +177,11 @@ export async function sendPollWhatsApp( const result = await active.sendPoll(to, normalized); const messageId = (result as { messageId?: string })?.messageId ?? "unknown"; const durationMs = Date.now() - startedAt; - outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); - logger.info({ jid, messageId }, "sent poll"); + outboundLog.info(`Sent poll ${messageId} -> ${redactedJid} (${durationMs}ms)`); + logger.info({ jid: redactedJid, messageId }, "sent poll"); return { messageId, toJid: jid }; } catch (err) { - logger.error( - { err: String(err), to, question: poll.question }, - "failed to send poll via web session", - ); + logger.error({ err: String(err), to: redactedTo }, "failed to send poll via web session"); throw err; } } From 3a653082d8cc296b81e15fd295239c4feb9f7dd9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:39:25 +0000 Subject: [PATCH 209/314] fix(config): align whatsapp enabled schema with auto-enable --- CHANGELOG.md | 1 + src/config/config.schema-regressions.test.ts | 12 ++++++++++++ src/config/plugin-auto-enable.test.ts | 18 ++++++++++++++++++ src/config/types.whatsapp.ts | 2 ++ src/config/zod-schema.providers-whatsapp.ts | 1 + 5 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079ad76cb82..99db9758816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. - Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. - Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) - Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. - Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. This ships in the next npm release. Thanks @nedlir for reporting. - Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index ff42403f868..c183b34fa8e 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -63,6 +63,18 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + it("accepts channels.whatsapp.enabled", () => { + const res = validateConfigObject({ + channels: { + whatsapp: { + enabled: true, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + it("rejects unsafe iMessage remoteHost", () => { const res = validateConfigObject({ channels: { diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 7f5779a1818..f3ef2961f4e 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; describe("applyPluginAutoEnable", () => { @@ -48,6 +49,23 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + it("keeps auto-enabled WhatsApp config schema-valid", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + env: {}, + }); + + expect(result.config.channels?.whatsapp?.enabled).toBe(true); + const validated = validateConfigObject(result.config); + expect(validated.ok).toBe(true); + }); + it("respects explicit disable", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 6fa99ea7b84..395ce3b06b2 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -36,6 +36,8 @@ export type WhatsAppAckReactionConfig = { }; type WhatsAppSharedConfig = { + /** Whether the WhatsApp channel is enabled. */ + enabled?: boolean; /** Direct message access policy (default: pairing). */ dmPolicy?: DmPolicy; /** Same-phone setup (bot uses your personal WhatsApp number). */ diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 92c6daeffc3..4387ed1abb5 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -32,6 +32,7 @@ const WhatsAppAckReactionSchema = z .optional(); const WhatsAppSharedSchema = z.object({ + enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), From 3d22af692ce4c66203ce4682f0262a9101d0f650 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 10:13:35 +0800 Subject: [PATCH 210/314] fix(whatsapp): suppress reasoning/thinking content from WhatsApp delivery The deliver callback in process-message.ts was forwarding all payload kinds (tool, block, final) to WhatsApp. Block payloads contain the model's reasoning/thinking content, which should only be visible in the internal web UI. This caused chain-of-thought to leak to end users as separate WhatsApp messages. Add an early return for non-final payloads so only the actual response is delivered to the WhatsApp channel, matching how Telegram already filters by info.kind === "final". Fixes #24954 Fixes #24605 Co-authored-by: Cursor --- src/web/auto-reply/monitor/process-message.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index cf3b4d60554..1c48d4141e9 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -368,6 +368,12 @@ export async function processMessage(params: { } }, deliver: async (payload: ReplyPayload, info) => { + if (info.kind !== "final") { + // Only deliver final replies to external messaging channels (WhatsApp). + // Block (reasoning/thinking) and tool updates are meant for the internal + // web UI only; sending them here leaks chain-of-thought to end users. + return; + } await deliverWebReply({ replyResult: payload, msg: params.msg, @@ -377,30 +383,23 @@ export async function processMessage(params: { chunkMode, replyLogger: params.replyLogger, connectionId: params.connectionId, - // Tool + block updates are noisy; skip their log lines. - skipLog: info.kind !== "final", + skipLog: false, tableMode, }); didSendReply = true; - if (info.kind === "tool") { - params.rememberSentText(payload.text, {}); - return; - } - const shouldLog = info.kind === "final" && payload.text ? true : undefined; + const shouldLog = payload.text ? true : undefined; params.rememberSentText(payload.text, { combinedBody, combinedBodySessionKey: params.route.sessionKey, logVerboseMessage: shouldLog, }); - if (info.kind === "final") { - const fromDisplay = - params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); - const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); - whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); - if (shouldLogVerbose()) { - const preview = payload.text != null ? elide(payload.text, 400) : ""; - whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); - } + const fromDisplay = + params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); + const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); + whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); + if (shouldLogVerbose()) { + const preview = payload.text != null ? elide(payload.text, 400) : ""; + whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); } }, onError: (err, info) => { From b5881d9ef44432cc97bbcf94e082616432f49c6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:46:38 +0000 Subject: [PATCH 211/314] fix: avoid WhatsApp silent turns with final-only delivery (#24962) (thanks @SidQin-cyber) --- CHANGELOG.md | 1 + .../process-message.inbound-contract.test.ts | 75 ++++++++++++++++++- src/web/auto-reply/monitor/process-message.ts | 7 +- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99db9758816..d7800cdfc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 0404ec43145..0acd4056fc9 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -9,6 +9,9 @@ let capturedDispatchParams: unknown; let sessionDir: string | undefined; let sessionStorePath: string; let backgroundTasks: Set>; +const { deliverWebReplyMock } = vi.hoisted(() => ({ + deliverWebReplyMock: vi.fn(async () => {}), +})); const defaultReplyLogger = { info: () => {}, @@ -24,6 +27,7 @@ function makeProcessMessageArgs(params: { cfg?: unknown; groupHistories?: Map>; groupHistory?: Array<{ sender: string; body: string }>; + rememberSentText?: (text: string | undefined, opts: unknown) => void; }) { return { // oxlint-disable-next-line typescript/no-explicit-any @@ -47,7 +51,8 @@ function makeProcessMessageArgs(params: { // oxlint-disable-next-line typescript/no-explicit-any replyLogger: defaultReplyLogger as any, backgroundTasks, - rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + rememberSentText: + params.rememberSentText ?? ((_text: string | undefined, _opts: unknown) => {}), echoHas: () => false, echoForget: () => {}, buildCombinedEchoKey: () => "echo", @@ -75,6 +80,10 @@ vi.mock("./last-route.js", () => ({ updateLastRouteInBackground: vi.fn(), })); +vi.mock("../deliver-reply.js", () => ({ + deliverWebReply: deliverWebReplyMock, +})); + import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { @@ -82,6 +91,7 @@ describe("web processMessage inbound contract", () => { capturedCtx = undefined; capturedDispatchParams = undefined; backgroundTasks = new Set(); + deliverWebReplyMock.mockClear(); sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-process-message-")); sessionStorePath = path.join(sessionDir, "sessions.json"); }); @@ -229,4 +239,67 @@ describe("web processMessage inbound contract", () => { expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0); }); + + it("suppresses non-final WhatsApp payload delivery", async () => { + const rememberSentText = vi.fn(); + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + rememberSentText, + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const deliver = (capturedDispatchParams as any)?.dispatcherOptions?.deliver as + | ((payload: { text?: string }, info: { kind: "tool" | "block" | "final" }) => Promise) + | undefined; + expect(deliver).toBeTypeOf("function"); + + await deliver?.({ text: "tool payload" }, { kind: "tool" }); + await deliver?.({ text: "block payload" }, { kind: "block" }); + expect(deliverWebReplyMock).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + + await deliver?.({ text: "final payload" }, { kind: "final" }); + expect(deliverWebReplyMock).toHaveBeenCalledTimes(1); + expect(rememberSentText).toHaveBeenCalledTimes(1); + }); + + it("forces disableBlockStreaming for WhatsApp dispatch", async () => { + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + cfg: { + channels: { whatsapp: { blockStreaming: true } }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType, + msg: { + id: "msg1", + from: "+1555", + to: "+2000", + chatType: "direct", + body: "hi", + }, + }), + ); + + // oxlint-disable-next-line typescript/no-explicit-any + const replyOptions = (capturedDispatchParams as any)?.replyOptions; + expect(replyOptions?.disableBlockStreaming).toBe(true); + }); }); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 1c48d4141e9..15607a4524e 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -416,10 +416,9 @@ export async function processMessage(params: { onReplyStart: params.msg.sendComposing, }, replyOptions: { - disableBlockStreaming: - typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean" - ? !params.cfg.channels.whatsapp.blockStreaming - : undefined, + // WhatsApp delivery intentionally suppresses non-final payloads. + // Keep block streaming disabled so final replies are still produced. + disableBlockStreaming: true, onModelSelected, }, }); From 1565d7e7b3d1b0863308a96c97dcd897094e89fd Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:14:49 +0000 Subject: [PATCH 212/314] fix: increase verification max_tokens to 1024 for Poe API compatibility Poe API's Extended Thinking models (e.g. claude-sonnet-4.6) require budget_tokens >= 1024. The previous values (5 for OpenAI, 16 for Anthropic) caused HTTP 400 errors during provider verification. Fixes #23433 Co-Authored-By: Claude Opus 4.6 --- src/commands/onboard-custom.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index aff71ce7f3d..a00471701b2 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -303,7 +303,7 @@ async function requestOpenAiVerification(params: { body: { model: params.modelId, messages: [{ role: "user", content: "Hi" }], - max_tokens: 5, + max_tokens: 1024, }, }); } @@ -329,7 +329,7 @@ async function requestAnthropicVerification(params: { headers: buildAnthropicHeaders(params.apiKey), body: { model: params.modelId, - max_tokens: 16, + max_tokens: 1024, messages: [{ role: "user", content: "Hi" }], }, }); From 9cc7450edf5569d069c22134999270e2ed76301d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:48:44 +0000 Subject: [PATCH 213/314] docs(changelog): add missing unreleased fixes and reorder --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7800cdfc65..ab56cece4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,26 +10,44 @@ Docs: https://docs.openclaw.ai ### Fixes +- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) +- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) +- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) +- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) +- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) +- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) +- Slack/Restart sentinel: map `threadId` to `replyToId` for restart sentinel notifications. (#24885) +- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) +- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) +- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) +- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) +- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) +- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) +- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) +- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) +- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. -- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. +- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. -- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. -- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. -- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. ## 2026.2.23 (Unreleased) From 947883d2e00f55b83ce03e278ed2ed8736c7ff24 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:29:22 +0000 Subject: [PATCH 214/314] fix: suppress sessions_send error warnings from leaking to chat (#23989) sessions_send timeout/error results were being surfaced as raw warning messages in Telegram chats because the tool is classified as mutating, which forces error warnings to always be shown. However, sessions_send failures are transient inter-session communication issues where the message may still have been delivered, so they should not leak to users. Co-Authored-By: Claude Opus 4.6 --- src/agents/pi-embedded-runner/run/payloads.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index f1ff4dda724..7b3d40c5d00 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -67,6 +67,12 @@ function resolveToolErrorWarningPolicy(params: { if ((normalizedToolName === "exec" || normalizedToolName === "bash") && !includeDetails) { return { showWarning: false, includeDetails }; } + // sessions_send timeouts and errors are transient inter-session communication + // issues — the message may still have been delivered. Suppress warnings to + // prevent raw error text from leaking into the chat surface (#23989). + if (normalizedToolName === "sessions_send") { + return { showWarning: false, includeDetails }; + } const isMutatingToolError = params.lastToolError.mutatingAction ?? isLikelyMutatingToolName(params.lastToolError.toolName); if (isMutatingToolError) { From dd145f1346c944e7b2df22283bd470afd971adaf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:48:56 +0000 Subject: [PATCH 215/314] fix: suppress sessions_send warning leakage coverage (#24740) (thanks @Glucksberg) --- CHANGELOG.md | 1 + .../pi-embedded-runner/run/payloads.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab56cece4cb..d283cb74fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 5d950f2ee10..ee8acd1d43e 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -60,4 +60,26 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { absentDetail, }); }); + + it("suppresses sessions_send errors to avoid leaking transient relay failures", () => { + const payloads = buildPayloads({ + lastToolError: { toolName: "sessions_send", error: "delivery timeout" }, + verboseLevel: "on", + }); + + expect(payloads).toHaveLength(0); + }); + + it("suppresses sessions_send errors even when marked mutating", () => { + const payloads = buildPayloads({ + lastToolError: { + toolName: "sessions_send", + error: "delivery timeout", + mutatingAction: true, + }, + verboseLevel: "on", + }); + + expect(payloads).toHaveLength(0); + }); }); From 9f4764cd417e11bc3167c2e8a6817b40e3c40ec7 Mon Sep 17 00:00:00 2001 From: pandego <7780875+pandego@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:28:34 +0100 Subject: [PATCH 216/314] fix(plugins): guard legacy zod schemas without toJSONSchema --- src/channels/plugins/config-schema.test.ts | 17 +++++++++++++++ src/channels/plugins/config-schema.ts | 24 ++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/channels/plugins/config-schema.test.ts diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts new file mode 100644 index 00000000000..2abd11e53af --- /dev/null +++ b/src/channels/plugins/config-schema.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { buildChannelConfigSchema } from "./config-schema.js"; + +describe("buildChannelConfigSchema", () => { + it("builds json schema when toJSONSchema is available", () => { + const schema = z.object({ enabled: z.boolean().default(true) }); + const result = buildChannelConfigSchema(schema); + expect(result.schema).toMatchObject({ type: "object" }); + }); + + it("falls back when toJSONSchema is missing (zod v3 plugin compatibility)", () => { + const legacySchema = {} as unknown as Parameters[0]; + const result = buildChannelConfigSchema(legacySchema); + expect(result.schema).toEqual({ type: "object", additionalProperties: true }); + }); +}); diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 50b81e83b92..75074ae569d 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -1,11 +1,27 @@ import type { ZodTypeAny } from "zod"; import type { ChannelConfigSchema } from "./types.plugin.js"; +type ZodSchemaWithToJsonSchema = ZodTypeAny & { + toJSONSchema?: (params?: Record) => unknown; +}; + export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema { + const schemaWithJson = schema as ZodSchemaWithToJsonSchema; + if (typeof schemaWithJson.toJSONSchema === "function") { + return { + schema: schemaWithJson.toJSONSchema({ + target: "draft-07", + unrepresentable: "any", + }) as Record, + }; + } + + // Compatibility fallback for plugins built against Zod v3 schemas, + // where `.toJSONSchema()` is unavailable. return { - schema: schema.toJSONSchema({ - target: "draft-07", - unrepresentable: "any", - }) as Record, + schema: { + type: "object", + additionalProperties: true, + }, }; } From 7a42558a3e3c1d29bc35a35159aee1f7566eb73f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:50:27 +0000 Subject: [PATCH 217/314] fix: harden legacy plugin schema compatibility tests (#24933) (thanks @pandego) --- CHANGELOG.md | 1 + src/channels/plugins/config-schema.test.ts | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d283cb74fd4..dc238731349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts index 2abd11e53af..93d65d728a5 100644 --- a/src/channels/plugins/config-schema.test.ts +++ b/src/channels/plugins/config-schema.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { buildChannelConfigSchema } from "./config-schema.js"; @@ -14,4 +14,23 @@ describe("buildChannelConfigSchema", () => { const result = buildChannelConfigSchema(legacySchema); expect(result.schema).toEqual({ type: "object", additionalProperties: true }); }); + + it("passes draft-07 compatibility options to toJSONSchema", () => { + const toJSONSchema = vi.fn(() => ({ + type: "object", + properties: { enabled: { type: "boolean" } }, + })); + const schema = { toJSONSchema } as unknown as Parameters[0]; + + const result = buildChannelConfigSchema(schema); + + expect(toJSONSchema).toHaveBeenCalledWith({ + target: "draft-07", + unrepresentable: "any", + }); + expect(result.schema).toEqual({ + type: "object", + properties: { enabled: { type: "boolean" } }, + }); + }); }); From 053b0df7d431abf45dd4b9615ddbbc4731ae33ad Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 23 Feb 2026 20:36:15 +0000 Subject: [PATCH 218/314] fix(ui): load saved locale on startup --- ui/src/i18n/lib/translate.ts | 37 +++++++++++++++++++----------- ui/src/i18n/test/translate.test.ts | 16 +++++++++++-- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/ui/src/i18n/lib/translate.ts b/ui/src/i18n/lib/translate.ts index 3b1cfa0978a..0a03226ff42 100644 --- a/ui/src/i18n/lib/translate.ts +++ b/ui/src/i18n/lib/translate.ts @@ -18,20 +18,30 @@ class I18nManager { this.loadLocale(); } - private loadLocale() { + private resolveInitialLocale(): Locale { const saved = localStorage.getItem("openclaw.i18n.locale"); if (isSupportedLocale(saved)) { - this.locale = saved; - } else { - const navLang = navigator.language; - if (navLang.startsWith("zh")) { - this.locale = navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; - } else if (navLang.startsWith("pt")) { - this.locale = "pt-BR"; - } else { - this.locale = "en"; - } + return saved; } + const navLang = navigator.language; + if (navLang.startsWith("zh")) { + return navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN"; + } + if (navLang.startsWith("pt")) { + return "pt-BR"; + } + return "en"; + } + + private loadLocale() { + const initialLocale = this.resolveInitialLocale(); + if (initialLocale === "en") { + this.locale = "en"; + return; + } + // Use the normal locale setter so startup locale loading follows the same + // translation-loading + notify path as manual locale changes. + void this.setLocale(initialLocale); } public getLocale(): Locale { @@ -39,12 +49,13 @@ class I18nManager { } public async setLocale(locale: Locale) { - if (this.locale === locale) { + const needsTranslationLoad = !this.translations[locale]; + if (this.locale === locale && !needsTranslationLoad) { return; } // Lazy load translations if needed - if (!this.translations[locale]) { + if (needsTranslationLoad) { try { let module: Record; if (locale === "zh-CN") { diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 8d6f32ef2d6..b06aa8d2d23 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect, beforeEach } from "vitest"; import { i18n, t } from "../lib/translate.ts"; describe("i18n", () => { - beforeEach(() => { + beforeEach(async () => { localStorage.clear(); // Reset to English - void i18n.setLocale("en"); + await i18n.setLocale("en"); }); it("should return the key if translation is missing", () => { @@ -28,4 +28,16 @@ describe("i18n", () => { // but let's assume it falls back to English for now. expect(t("common.health")).toBeDefined(); }); + + it("loads translations even when setting the same locale again", async () => { + const internal = i18n as unknown as { + locale: string; + translations: Record; + }; + internal.locale = "zh-CN"; + delete internal.translations["zh-CN"]; + + await i18n.setLocale("zh-CN"); + expect(t("common.health")).toBe("健康状况"); + }); }); From fd24b354498db5cf8b74606949dd564bf9a77f83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:51:31 +0000 Subject: [PATCH 219/314] fix: cover startup locale hydration path (#24795) (thanks @chilu18) --- CHANGELOG.md | 1 + ui/src/i18n/test/translate.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc238731349..fe7ba34ffb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index b06aa8d2d23..178fd12b1e3 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { i18n, t } from "../lib/translate.ts"; describe("i18n", () => { @@ -40,4 +40,17 @@ describe("i18n", () => { await i18n.setLocale("zh-CN"); expect(t("common.health")).toBe("健康状况"); }); + + it("loads saved non-English locale on startup", async () => { + localStorage.setItem("openclaw.i18n.locale", "zh-CN"); + vi.resetModules(); + const fresh = await import("../lib/translate.ts"); + + for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) { + await Promise.resolve(); + } + + expect(fresh.i18n.getLocale()).toBe("zh-CN"); + expect(fresh.t("common.health")).toBe("健康状况"); + }); }); From d883ecade638c2c05454a59e77beeba3432b4510 Mon Sep 17 00:00:00 2001 From: Zongxin Yang Date: Mon, 23 Feb 2026 19:20:06 -0500 Subject: [PATCH 220/314] fix(discord): fallback thread parent lookup when parentId missing --- .../monitor/threading.parent-info.test.ts | 47 +++++++++++++++++++ src/discord/monitor/threading.ts | 6 ++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/discord/monitor/threading.parent-info.test.ts diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts new file mode 100644 index 00000000000..8ad36c11f94 --- /dev/null +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -0,0 +1,47 @@ +import { ChannelType } from "@buape/carbon"; +import { describe, expect, it, vi } from "vitest"; +import { resolveDiscordThreadParentInfo } from "./threading.js"; + +describe("resolveDiscordThreadParentInfo", () => { + it("falls back to fetched thread parentId when parentId is missing in payload", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: "parent-1", + }; + } + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { + fetchChannel, + } as unknown as import("@buape/carbon").Client; + + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: undefined, + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); +}); diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 4efc83d0c74..877329c2995 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -131,8 +131,12 @@ export async function resolveDiscordThreadParentInfo(params: { channelInfo: import("./message-utils.js").DiscordChannelInfo | null; }): Promise { const { threadChannel, channelInfo, client } = params; - const parentId = + let parentId = threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined; + if (!parentId && threadChannel.id) { + const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id); + parentId = threadInfo?.parentId ?? undefined; + } if (!parentId) { return {}; } From a216f2dabe77185f197d8e2f71f07705bcf024b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:52:27 +0000 Subject: [PATCH 221/314] fix: extend discord thread parent fallback coverage (#24897) (thanks @z-x-yang) --- CHANGELOG.md | 1 + .../monitor/threading.parent-info.test.ts | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7ba34ffb5..d7851591e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts index 8ad36c11f94..1954dd4fe9d 100644 --- a/src/discord/monitor/threading.parent-info.test.ts +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -44,4 +44,63 @@ describe("resolveDiscordThreadParentInfo", () => { type: ChannelType.GuildText, }); }); + + it("does not fetch thread info when parentId is already present", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "parent-1") { + return { + id: "parent-1", + type: ChannelType.GuildText, + name: "parent-name", + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: "parent-1", + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + expect(fetchChannel).toHaveBeenCalledWith("parent-1"); + expect(result).toEqual({ + id: "parent-1", + name: "parent-name", + type: ChannelType.GuildText, + }); + }); + + it("returns empty parent info when fallback thread lookup has no parentId", async () => { + const fetchChannel = vi.fn(async (channelId: string) => { + if (channelId === "thread-1") { + return { + id: "thread-1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: undefined, + }; + } + return null; + }); + + const client = { fetchChannel } as unknown as import("@buape/carbon").Client; + const result = await resolveDiscordThreadParentInfo({ + client, + threadChannel: { + id: "thread-1", + parentId: undefined, + }, + channelInfo: null, + }); + + expect(fetchChannel).toHaveBeenCalledTimes(1); + expect(fetchChannel).toHaveBeenCalledWith("thread-1"); + expect(result).toEqual({}); + }); }); From 6c1ed9493c6086aec1c5223f82973358b0fb1e97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:52:31 +0000 Subject: [PATCH 222/314] fix: harden queue retry debounce and add regression tests --- extensions/synology-chat/src/channel.test.ts | 35 +++++++++++++ .../pi-embedded-runner/run/attempt.test.ts | 17 ++++++- src/agents/pi-embedded-runner/run/attempt.ts | 9 +++- src/agents/subagent-announce-queue.test.ts | 49 +++++++++++++++++++ src/agents/subagent-announce-queue.ts | 8 +-- .../monitor/message-handler.process.test.ts | 20 +++++++- src/gateway/agent-prompt.test.ts | 49 +++++++++++++++++++ src/gateway/sessions-patch.test.ts | 32 ++++++++++++ src/plugins/install.test.ts | 44 +++++++++++++++++ 9 files changed, 257 insertions(+), 6 deletions(-) diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 622c7bffaed..076339c4456 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -39,6 +39,7 @@ vi.mock("zod", () => ({ })); const { createSynologyChatPlugin } = await import("./channel.js"); +const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk"); describe("createSynologyChatPlugin", () => { it("returns a plugin object with all required sections", () => { @@ -336,5 +337,39 @@ describe("createSynologyChatPlugin", () => { const result = await plugin.gateway.startAccount(ctx); expect(typeof result.stop).toBe("function"); }); + + it("deregisters stale route before re-registering same account/path", async () => { + const unregisterFirst = vi.fn(); + const unregisterSecond = vi.fn(); + const registerMock = vi.mocked(registerPluginHttpRoute); + registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond); + + const plugin = createSynologyChatPlugin(); + const ctx = { + cfg: { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + webhookPath: "/webhook/synology", + }, + }, + }, + accountId: "default", + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const first = await plugin.gateway.startAccount(ctx); + const second = await plugin.gateway.startAccount(ctx); + + expect(registerMock).toHaveBeenCalledTimes(2); + expect(unregisterFirst).toHaveBeenCalledTimes(1); + expect(unregisterSecond).not.toHaveBeenCalled(); + + // Clean up active route map so this module-level state doesn't leak across tests. + first.stop(); + second.stop(); + }); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 8dcd25a415a..ab25ce57e86 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,7 +1,11 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; -import { injectHistoryImagesIntoMessages, resolvePromptBuildHookResult } from "./attempt.js"; +import { + injectHistoryImagesIntoMessages, + resolvePromptBuildHookResult, + resolvePromptModeForSession, +} from "./attempt.js"; describe("injectHistoryImagesIntoMessages", () => { const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" }; @@ -103,3 +107,14 @@ describe("resolvePromptBuildHookResult", () => { expect(result.prependContext).toBe("from-hook"); }); }); + +describe("resolvePromptModeForSession", () => { + it("uses minimal mode for subagent sessions", () => { + expect(resolvePromptModeForSession("agent:main:subagent:child")).toBe("minimal"); + }); + + it("uses full mode for cron sessions", () => { + expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("full"); + expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 12d246e8a30..9406afae943 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -221,6 +221,13 @@ export async function resolvePromptBuildHookResult(params: { }; } +export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "full" { + if (!sessionKey) { + return "full"; + } + return isSubagentSessionKey(sessionKey) ? "minimal" : "full"; +} + function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { const content = (msg as { content?: unknown }).content; if (typeof content === "string") { @@ -494,7 +501,7 @@ export async function runEmbeddedAttempt( }, }); const isDefaultAgent = sessionAgentId === defaultAgentId; - const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; + const promptMode = resolvePromptModeForSession(params.sessionKey); const docsPath = await resolveOpenClawDocsPath({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], diff --git a/src/agents/subagent-announce-queue.test.ts b/src/agents/subagent-announce-queue.test.ts index 6e673cd2fda..b638b2fad3f 100644 --- a/src/agents/subagent-announce-queue.test.ts +++ b/src/agents/subagent-announce-queue.test.ts @@ -27,6 +27,7 @@ function createRetryingSend() { describe("subagent-announce-queue", () => { afterEach(() => { + vi.useRealTimers(); resetAnnounceQueuesForTests(); }); @@ -116,4 +117,52 @@ describe("subagent-announce-queue", () => { expect(sender.prompts[1]).toContain("Queued #2"); expect(sender.prompts[1]).toContain("queued item two"); }); + + it("uses debounce floor for retries when debounce exceeds backoff", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const previousFast = process.env.OPENCLAW_TEST_FAST; + delete process.env.OPENCLAW_TEST_FAST; + + try { + const attempts: number[] = []; + const send = vi.fn(async () => { + attempts.push(Date.now()); + if (attempts.length === 1) { + throw new Error("transient timeout"); + } + }); + + enqueueAnnounce({ + key: "announce:test:retry-debounce-floor", + item: { + prompt: "subagent completed", + enqueuedAt: Date.now(), + sessionKey: "agent:main:telegram:dm:u1", + }, + settings: { mode: "followup", debounceMs: 5_000 }, + send, + }); + + await vi.advanceTimersByTimeAsync(5_000); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(4_999); + expect(send).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(2); + const [firstAttempt, secondAttempt] = attempts; + if (firstAttempt === undefined || secondAttempt === undefined) { + throw new Error("expected two retry attempts"); + } + expect(secondAttempt - firstAttempt).toBeGreaterThanOrEqual(5_000); + } finally { + if (previousFast === undefined) { + delete process.env.OPENCLAW_TEST_FAST; + } else { + process.env.OPENCLAW_TEST_FAST = previousFast; + } + } + }); }); diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index 611541c186e..cd99372adc8 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -183,9 +183,10 @@ function scheduleAnnounceDrain(key: string) { queue.consecutiveFailures++; // Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s. const errorBackoffMs = Math.min(1000 * Math.pow(2, queue.consecutiveFailures), 60_000); - queue.lastEnqueuedAt = Date.now() + errorBackoffMs - queue.debounceMs; + const retryDelayMs = Math.max(errorBackoffMs, queue.debounceMs); + queue.lastEnqueuedAt = Date.now() + retryDelayMs - queue.debounceMs; defaultRuntime.error?.( - `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(errorBackoffMs / 1000)}s): ${String(err)}`, + `announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(retryDelayMs / 1000)}s): ${String(err)}`, ); } finally { queue.draining = false; @@ -205,7 +206,8 @@ export function enqueueAnnounce(params: { send: (item: AnnounceQueueItem) => Promise; }): boolean { const queue = getAnnounceQueue(params.key, params.settings, params.send); - queue.lastEnqueuedAt = Date.now(); + // Preserve any retry backoff marker already encoded in lastEnqueuedAt. + queue.lastEnqueuedAt = Math.max(queue.lastEnqueuedAt, Date.now()); const shouldEnqueue = applyQueueDropPolicy({ queue, diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index f3d2c7bcf15..067273351db 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -31,6 +31,7 @@ const deliverDiscordReply = deliveryMocks.deliverDiscordReply; const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream; type DispatchInboundParams = { dispatcher: { + sendBlockReply: (payload: { text?: string }) => boolean | Promise; sendFinalReply: (payload: { text?: string }) => boolean | Promise; }; replyOptions?: { @@ -75,7 +76,10 @@ vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ (opts: { deliver: (payload: unknown, info: { kind: string }) => Promise | void }) => ({ dispatcher: { sendToolResult: vi.fn(() => true), - sendBlockReply: vi.fn(() => true), + sendBlockReply: vi.fn((payload: unknown) => { + void opts.deliver(payload as never, { kind: "block" }); + return true; + }), sendFinalReply: vi.fn((payload: unknown) => { void opts.deliver(payload as never, { kind: "final" }); return true; @@ -423,6 +427,20 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("suppresses block-kind payload delivery to Discord", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendBlockReply({ text: "thinking..." }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 1 } }; + }); + + const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + it("streams block previews using draft chunking", async () => { const draftStream = createMockDraftStream(); createDiscordDraftStream.mockReturnValueOnce(draftStream); diff --git a/src/gateway/agent-prompt.test.ts b/src/gateway/agent-prompt.test.ts index 80fc92e4819..75800696614 100644 --- a/src/gateway/agent-prompt.test.ts +++ b/src/gateway/agent-prompt.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildHistoryContextFromEntries } from "../auto-reply/reply/history.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; import { buildAgentMessageFromConversationEntries } from "./agent-prompt.js"; describe("gateway agent prompt", () => { @@ -15,6 +16,24 @@ describe("gateway agent prompt", () => { ).toBe("hi"); }); + it("extracts text from content-array body when there is no history", () => { + expect( + buildAgentMessageFromConversationEntries([ + { + role: "user", + entry: { + sender: "User", + body: [ + { type: "text", text: "hi" }, + { type: "image", data: "base64-image", mimeType: "image/png" }, + { type: "text", text: "there" }, + ] as unknown as string, + }, + }, + ]), + ).toBe("hi there"); + }); + it("uses history context when there is history", () => { const entries = [ { role: "assistant", entry: { sender: "Assistant", body: "prev" } }, @@ -45,4 +64,34 @@ describe("gateway agent prompt", () => { expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); }); + + it("normalizes content-array bodies in history and current message", () => { + const entries = [ + { + role: "assistant", + entry: { + sender: "Assistant", + body: [{ type: "text", text: "prev" }] as unknown as string, + }, + }, + { + role: "user", + entry: { + sender: "User", + body: [ + { type: "text", text: "next" }, + { type: "text", text: "step" }, + ] as unknown as string, + }, + }, + ] as const; + + const expected = buildHistoryContextFromEntries({ + entries: entries.map((e) => e.entry), + currentMessage: "User: next step", + formatEntry: (e) => `${e.sender}: ${extractTextFromChatContent(e.body) ?? ""}`, + }); + + expect(buildAgentMessageFromConversationEntries([...entries])).toBe(expected); + }); }); diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 3d8c575cf66..1e3d92b33df 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -87,6 +87,38 @@ describe("gateway sessions patch", () => { expect(res.entry.thinkingLevel).toBeUndefined(); }); + test("persists reasoningLevel=off (does not clear)", async () => { + const store: Record = {}; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", reasoningLevel: "off" }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.reasoningLevel).toBe("off"); + }); + + test("clears reasoningLevel when patch sets null", async () => { + const store: Record = { + "agent:main:main": { reasoningLevel: "stream" } as SessionEntry, + }; + const res = await applySessionsPatchToStore({ + cfg: {} as OpenClawConfig, + store, + storeKey: "agent:main:main", + patch: { key: "agent:main:main", reasoningLevel: null }, + }); + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.entry.reasoningLevel).toBeUndefined(); + }); + test("persists elevatedLevel=off (does not clear)", async () => { const store: Record = {}; const res = await applySessionsPatchToStore({ diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 87409e7eee0..1bc7a359b85 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -513,6 +513,50 @@ describe("installPluginFromDir", () => { expect(manifest.devDependencies?.openclaw).toBeUndefined(); expect(manifest.devDependencies?.vitest).toBe("^3.0.0"); }); + + it("uses openclaw.plugin.json id as install key when it differs from package name", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cognee-openclaw", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "memory-cognee", + configSchema: { type: "object", properties: {} }, + }), + "utf-8", + ); + + const infoMessages: string[] = []; + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + logger: { info: (msg: string) => infoMessages.push(msg), warn: () => {} }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("memory-cognee"); + expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee")); + expect( + infoMessages.some((msg) => + msg.includes( + 'Plugin manifest id "memory-cognee" differs from npm package name "cognee-openclaw"', + ), + ), + ).toBe(true); + }); }); describe("installPluginFromNpmSpec", () => { From b902d5ade0b5cbc10313098390872c2b5757675e Mon Sep 17 00:00:00 2001 From: Mark Musson Date: Mon, 23 Feb 2026 19:52:39 +0000 Subject: [PATCH 223/314] fix(status): show pairing approval recovery hints --- src/commands/status.command.ts | 33 ++++++++++++++++++++++++ src/commands/status.test.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index e06feb42af5..d21ae16f176 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -37,6 +37,21 @@ import { resolveUpdateAvailability, } from "./status.update.js"; +function resolvePairingRecoveryContext(params: { + error?: string | null; + closeReason?: string | null; +}): { requestId: string | null } | null { + const source = [params.error, params.closeReason] + .filter((part) => typeof part === "string" && part.trim().length > 0) + .join(" "); + if (!source || !/pairing required/i.test(source)) { + return null; + } + const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); + const requestId = requestIdMatch && requestIdMatch[1] ? requestIdMatch[1].trim() : ""; + return { requestId: requestId || null }; +} + export async function statusCommand( opts: { json?: boolean; @@ -230,6 +245,10 @@ export async function statusCommand( const suffix = self ? ` · ${self}` : ""; return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`; })(); + const pairingRecovery = resolvePairingRecoveryContext({ + error: gatewayProbe?.error ?? null, + closeReason: gatewayProbe?.close?.reason ?? null, + }); const agentsValue = (() => { const pending = @@ -399,6 +418,20 @@ export async function statusCommand( }).trimEnd(), ); + if (pairingRecovery) { + runtime.log(""); + runtime.log(theme.warn("Gateway pairing approval required.")); + if (pairingRecovery.requestId) { + runtime.log( + theme.muted( + `Recovery: ${formatCliCommand(`openclaw devices approve ${pairingRecovery.requestId}`)}`, + ), + ); + } + runtime.log(theme.muted(`Fallback: ${formatCliCommand("openclaw devices approve --latest")}`)); + runtime.log(theme.muted(`Inspect: ${formatCliCommand("openclaw devices list")}`)); + } + runtime.log(""); runtime.log(theme.heading("Security audit")); const fmtSummary = (value: { critical: number; warn: number; info: number }) => { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 1275c0bea2c..8092469f588 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -479,6 +479,52 @@ describe("statusCommand", () => { expect(logs.join("\n")).toMatch(/WARN/); }); + it("prints requestId-aware recovery guidance when gateway pairing is required", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123)", + close: { code: 1008, reason: "pairing required (requestId: req-123)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const joined = logs.join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).toContain("devices approve req-123"); + expect(joined).toContain("devices approve --latest"); + expect(joined).toContain("devices list"); + }); + + it("prints fallback recovery guidance when pairing requestId is unavailable", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "connect failed" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])); + const joined = logs.join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-"); + expect(joined).toContain("devices approve --latest"); + expect(joined).toContain("devices list"); + }); + it("includes sessions across agents in JSON output", async () => { const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); From 69a541c3f0e1cbe1f49c224761e76368d74b4f83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:53:29 +0000 Subject: [PATCH 224/314] fix: sanitize pairing recovery requestId hints (#24771) (thanks @markmusson) --- CHANGELOG.md | 1 + src/commands/status.command.ts | 14 +++++++++++- src/commands/status.test.ts | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7851591e0f..d8d550448df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index d21ae16f176..a613f0896ee 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -41,6 +41,17 @@ function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; }): { requestId: string | null } | null { + const sanitizeRequestId = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + // Keep CLI guidance injection-safe: allow only compact id characters. + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trimmed)) { + return null; + } + return trimmed; + }; const source = [params.error, params.closeReason] .filter((part) => typeof part === "string" && part.trim().length > 0) .join(" "); @@ -48,7 +59,8 @@ function resolvePairingRecoveryContext(params: { return null; } const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i); - const requestId = requestIdMatch && requestIdMatch[1] ? requestIdMatch[1].trim() : ""; + const requestId = + requestIdMatch && requestIdMatch[1] ? sanitizeRequestId(requestIdMatch[1]) : null; return { requestId: requestId || null }; } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 8092469f588..4532acb3ea2 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -525,6 +525,46 @@ describe("statusCommand", () => { expect(joined).toContain("devices list"); }); + it("does not render unsafe requestId content into approval command hints", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required (requestId: req-123;rm -rf /)", + close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("Gateway pairing approval required."); + expect(joined).not.toContain("devices approve req-123;rm -rf /"); + expect(joined).toContain("devices approve --latest"); + }); + + it("extracts requestId from close reason when error text omits it", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: pairing required", + close: { code: 1008, reason: "pairing required (requestId: req-close-456)" }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + runtimeLogMock.mockClear(); + await statusCommand({}, runtime as never); + const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); + expect(joined).toContain("devices approve req-close-456"); + }); + it("includes sessions across agents in JSON output", async () => { const originalAgents = mocks.listAgentsForGateway.getMockImplementation(); const originalResolveStorePath = mocks.resolveStorePath.getMockImplementation(); From 3e974dc93f45c6e2847cab681244c3854505f7ec Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 23 Feb 2026 23:07:50 +0000 Subject: [PATCH 225/314] fix: don't inject reasoning: { effort: "none" } for OpenRouter when thinking is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "off" is a truthy string, so the existing guard `if (thinkingLevel && ...)` was always entering the injection block and sending `reasoning: { effort: "none" }` to every OpenRouter request — even when thinking wasn't enabled. Models that require reasoning (e.g. deepseek/deepseek-r1) reject this with: 400 Reasoning is mandatory for this endpoint and cannot be disabled. Fix: skip the reasoning injection entirely when thinkingLevel is "off". The reasoning_effort flat-field cleanup still runs. Omitting the reasoning field lets each model use its own default behavior. Co-Authored-By: Claude Sonnet 4.6 --- .../pi-embedded-runner-extraparams.test.ts | 52 +++++++++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 39 ++++++++------ 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index a6d3e9191e8..3f5189a40ea 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -202,6 +202,58 @@ describe("applyExtraParamsToAgent", () => { return calls[0]?.headers; } + it("does not inject reasoning when thinkingLevel is off (default) for OpenRouter", () => { + // Regression: "off" is a truthy string, so the old code injected + // reasoning: { effort: "none" }, causing a 400 on models that require + // reasoning (e.g. deepseek/deepseek-r1). + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { model: "deepseek/deepseek-r1" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "deepseek/deepseek-r1", undefined, "off"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "deepseek/deepseek-r1", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).not.toHaveProperty("reasoning"); + expect(payloads[0]).not.toHaveProperty("reasoning_effort"); + }); + + it("injects reasoning.effort when thinkingLevel is non-off for OpenRouter", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.reasoning).toEqual({ effort: "low" }); + }); + it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 8ebacf6df68..66b077af232 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -435,24 +435,31 @@ function createOpenRouterWrapper( // only the nested one is sent. delete payloadObj.reasoning_effort; - const existingReasoning = payloadObj.reasoning; + // When thinking is "off", do not inject reasoning at all. + // Some models (e.g. deepseek/deepseek-r1) require reasoning and reject + // { effort: "none" } with "Reasoning is mandatory for this endpoint and + // cannot be disabled." Omitting the field lets each model use its own + // default reasoning behavior. + if (thinkingLevel !== "off") { + const existingReasoning = payloadObj.reasoning; - // OpenRouter treats reasoning.effort and reasoning.max_tokens as - // alternative controls. If max_tokens is already present, do not - // inject effort and do not overwrite caller-supplied reasoning. - if ( - existingReasoning && - typeof existingReasoning === "object" && - !Array.isArray(existingReasoning) - ) { - const reasoningObj = existingReasoning as Record; - if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { - reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + // OpenRouter treats reasoning.effort and reasoning.max_tokens as + // alternative controls. If max_tokens is already present, do not + // inject effort and do not overwrite caller-supplied reasoning. + if ( + existingReasoning && + typeof existingReasoning === "object" && + !Array.isArray(existingReasoning) + ) { + const reasoningObj = existingReasoning as Record; + if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { + reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + } + } else if (!existingReasoning) { + payloadObj.reasoning = { + effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), + }; } - } else if (!existingReasoning) { - payloadObj.reasoning = { - effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), - }; } } onPayload?.(payload); From b96d32c1c25486601667bbfc9902b241ec586170 Mon Sep 17 00:00:00 2001 From: Tim Jones Date: Mon, 23 Feb 2026 23:13:27 +0000 Subject: [PATCH 226/314] chore: fix oxfmt formatting in extraparams test Co-Authored-By: Claude Sonnet 4.6 --- src/agents/pi-embedded-runner-extraparams.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 3f5189a40ea..c96bb069a0e 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -215,7 +215,14 @@ describe("applyExtraParamsToAgent", () => { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "openrouter", "deepseek/deepseek-r1", undefined, "off"); + applyExtraParamsToAgent( + agent, + undefined, + "openrouter", + "deepseek/deepseek-r1", + undefined, + "off", + ); const model = { api: "openai-completions", From de0e01259a9ba5a73b2d9a527505044f1f7a1e7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:54:15 +0000 Subject: [PATCH 227/314] fix: expand openrouter thinking-off regression coverage (#24863) (thanks @DevSecTim) --- CHANGELOG.md | 1 + .../pi-embedded-runner-extraparams.test.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d550448df..c6ee9ff1d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. - Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index c96bb069a0e..1e47be3ee1f 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -261,6 +261,55 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]?.reasoning).toEqual({ effort: "low" }); }); + it("removes legacy reasoning_effort and keeps reasoning unset when thinkingLevel is off", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning_effort: "high" }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "off"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).not.toHaveProperty("reasoning_effort"); + expect(payloads[0]).not.toHaveProperty("reasoning"); + }); + + it("does not inject effort when payload already has reasoning.max_tokens", () => { + const payloads: Record[] = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + const payload: Record = { reasoning: { max_tokens: 256 } }; + options?.onPayload?.(payload); + payloads.push(payload); + return {} as ReturnType; + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto", undefined, "low"); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: "openrouter/auto", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + void agent.streamFn?.(model, context, {}); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toEqual({ reasoning: { max_tokens: 256 } }); + }); + it("adds OpenRouter attribution headers to stream options", () => { const { calls, agent } = createOptionsCaptureAgent(); From 6f44d92d7677f1d42d3b14ad17c2e79450458991 Mon Sep 17 00:00:00 2001 From: shenghui kevin Date: Sun, 22 Feb 2026 22:03:42 -0800 Subject: [PATCH 228/314] docs: update PR_STATUS.md - all 11 PRs CI passed --- PR_STATUS.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 PR_STATUS.md diff --git a/PR_STATUS.md b/PR_STATUS.md new file mode 100644 index 00000000000..1887eca27d9 --- /dev/null +++ b/PR_STATUS.md @@ -0,0 +1,78 @@ +# OpenClaw PR Submission Status + +> Auto-maintained by agent team. Last updated: 2026-02-22 + +## PR Plan Overview + +All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. +Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +## Duplicate Check + +Before submission, each PR was cross-referenced against: + +- 100+ open upstream PRs (as of 2026-02-22) +- 50 recently merged PRs +- 50+ open issues + +No overlap found with existing PRs. + +## PR Status Table + +| # | Branch | Title | Type | Status | PR URL | +| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | +| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | +| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | +| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | +| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | +| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | +| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | +| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | +| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | +| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | +| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | +| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | + +## Isolation Rules + +- Each agent works on a separate git worktree branch +- No two agents modify the same file +- File ownership: + - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` + - PR 2: `src/agents/session-slug.ts` + - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` + - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` + - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` + - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` + - PR 7: `src/telegram/bot-message-dispatch.ts` + - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` + - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` + - PR 10: `src/agents/skills-install-download.ts` + - PR 11: `src/browser/extension-relay.ts` + +## Verification Results + +### Batch 1 (PRs 1-4) — All CI Green + +- PR 1: 17 tests pass, check/build/tests all green +- PR 2: 3 tests pass, check/build/tests all green +- PR 3: 45 tests pass (3 new), check/build/tests all green +- PR 4: 12 tests pass, check/build/tests all green + +### Batch 2 (PRs 5-7) — CI Running + +- PR 5: 3 signal tests pass, check pass, awaiting full test suite +- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite +- PR 7: 47 tests pass (3 new), check pass, awaiting full suite + +### Batch 3 (PRs 8-9) — All CI Green + +- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. +- PR 8: 17/17 pass, check/build/tests/windows all green +- PR 9: 18/18 pass, check/build/tests/windows all green + +### Batch 4 (PRs 10-11) — All CI Green + +- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). +- PR 10: 19/19 pass, check/build/tests/windows all green +- PR 11: 20/20 pass, check/build/tests/windows all green From 57783680ade2a8f668b5ad89b844efa23e771662 Mon Sep 17 00:00:00 2001 From: shenghui kevin Date: Mon, 23 Feb 2026 17:56:51 -0800 Subject: [PATCH 229/314] fix(whatsapp): guard updateLastRoute when dmScope isolates DM sessions When session.dmScope is set to 'per-channel-peer', WhatsApp DMs correctly resolve isolated session keys, but updateLastRouteInBackground unconditionally wrote lastTo to the main session key. This caused reply routing corruption and privacy violations. Only update main session's lastRoute when the DM session actually IS the main session (sessionKey === mainSessionKey). Fixes #24912 --- src/web/auto-reply/monitor/process-message.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 15607a4524e..3ef85b6eb2d 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -324,7 +324,10 @@ export async function processMessage(params: { OriginatingTo: params.msg.from, }); - if (dmRouteTarget) { + // Only update main session's lastRoute when DM actually IS the main session. + // When dmScope="per-channel-peer", the DM uses an isolated sessionKey, + // and updating mainSessionKey would corrupt routing for the session owner. + if (dmRouteTarget && params.route.sessionKey === params.route.mainSessionKey) { updateLastRouteInBackground({ cfg: params.cfg, backgroundTasks: params.backgroundTasks, From ebde897bb8066c6e824da123f9826b1cb176c262 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:55:10 +0000 Subject: [PATCH 230/314] fix: add dmScope route guard regression tests (#24949) (thanks @kevinWangSheng) --- CHANGELOG.md | 1 + .../process-message.inbound-contract.test.ts | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ee9ff1d93..e0421383603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. - Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 0acd4056fc9..8458487d8e9 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -84,6 +84,7 @@ vi.mock("../deliver-reply.js", () => ({ deliverWebReply: deliverWebReplyMock, })); +import { updateLastRouteInBackground } from "./last-route.js"; import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { @@ -302,4 +303,58 @@ describe("web processMessage inbound contract", () => { const replyOptions = (capturedDispatchParams as any)?.replyOptions; expect(replyOptions?.disableBlockStreaming).toBe(true); }); + + it("updates main last route for DM when session key matches main session key", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1000", + groupHistoryKey: "+1000", + msg: { + id: "msg-last-route-1", + from: "+1000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+1000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:whatsapp:direct:+1000", + mainSessionKey: "agent:main:whatsapp:direct:+1000", + }; + + await processMessage(args); + + expect(updateLastRouteMock).toHaveBeenCalledTimes(1); + }); + + it("does not update main last route for isolated DM scope sessions", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", + groupHistoryKey: "+3000", + msg: { + id: "msg-last-route-2", + from: "+3000", + to: "+2000", + chatType: "direct", + body: "hello", + senderE164: "+3000", + }, + }); + args.route = { + ...args.route, + sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000", + mainSessionKey: "agent:main:whatsapp:direct:+1000", + }; + + await processMessage(args); + + expect(updateLastRouteMock).not.toHaveBeenCalled(); + }); }); From a388fbb6c3129f48a67f9f3db788c83f1d545bb7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:47:32 +0000 Subject: [PATCH 231/314] fix: harden custom-provider verification probes (#24743) (thanks @Glucksberg) --- CHANGELOG.md | 1 + src/commands/onboard-custom.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0421383603..036017f16e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. - Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index c1bf8aa0d8d..c79c30daff2 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -116,6 +116,35 @@ describe("promptCustomApiConfig", () => { expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result }); }); + it("uses expanded max_tokens for openai verification probes", async () => { + const prompter = createTestPrompter({ + text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"], + select: ["openai"], + }); + const fetchMock = stubFetchSequence([{ ok: true }]); + + await runPromptCustomApi(prompter); + + const firstCall = fetchMock.mock.calls[0]?.[1] as { body?: string } | undefined; + expect(firstCall?.body).toBeDefined(); + expect(JSON.parse(firstCall?.body ?? "{}")).toMatchObject({ max_tokens: 1024 }); + }); + + it("uses expanded max_tokens for anthropic verification probes", async () => { + const prompter = createTestPrompter({ + text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], + select: ["unknown"], + }); + const fetchMock = stubFetchSequence([{ ok: false, status: 404 }, { ok: true }]); + + await runPromptCustomApi(prompter); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondCall = fetchMock.mock.calls[1]?.[1] as { body?: string } | undefined; + expect(secondCall?.body).toBeDefined(); + expect(JSON.parse(secondCall?.body ?? "{}")).toMatchObject({ max_tokens: 1024 }); + }); + it("re-prompts base url when unknown detection fails", async () => { const prompter = createTestPrompter({ text: [ From d76742ff88d58288a5591a19713858822faf99ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:56:27 +0000 Subject: [PATCH 232/314] fix: normalize manifest plugin ids during install --- src/plugins/install.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/plugins/install.ts | 4 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 1bc7a359b85..9f67e69430b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -557,6 +557,43 @@ describe("installPluginFromDir", () => { ), ).toBe(true); }); + + it("normalizes scoped manifest ids to unscoped install keys", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cognee-openclaw", + version: "0.0.1", + openclaw: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "@team/memory-cognee", + configSchema: { type: "object", properties: {} }, + }), + "utf-8", + ); + + const res = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + expectedPluginId: "memory-cognee", + logger: { info: () => {}, warn: () => {} }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.pluginId).toBe("memory-cognee"); + expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee")); + }); }); describe("installPluginFromNpmSpec", () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 49ce72dcd07..baf3eb690ad 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -158,7 +158,9 @@ async function installPluginFromPackageDir(params: { // uses the manifest id as the authoritative key, so the config entry must match it. const ocManifestResult = loadPluginManifest(params.packageDir); const manifestPluginId = - ocManifestResult.ok && ocManifestResult.manifest.id ? ocManifestResult.manifest.id : undefined; + ocManifestResult.ok && ocManifestResult.manifest.id + ? unscopedPackageName(ocManifestResult.manifest.id) + : undefined; const pluginId = manifestPluginId ?? npmPluginId; const pluginIdError = validatePluginId(pluginId); From 588a188d6f88ed418b6d38943439601817f98f91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:01:41 +0000 Subject: [PATCH 233/314] fix: replace stale plugin webhook routes on re-registration --- src/plugins/http-registry.test.ts | 78 +++++++++++++++++++++++++++++++ src/plugins/http-registry.ts | 7 +-- 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/plugins/http-registry.test.ts diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts new file mode 100644 index 00000000000..fca12e4dc11 --- /dev/null +++ b/src/plugins/http-registry.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerPluginHttpRoute } from "./http-registry.js"; +import { createEmptyPluginRegistry } from "./registry.js"; + +describe("registerPluginHttpRoute", () => { + it("registers route and unregisters it", () => { + const registry = createEmptyPluginRegistry(); + const handler = vi.fn(); + + const unregister = registerPluginHttpRoute({ + path: "/plugins/demo", + handler, + registry, + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo"); + expect(registry.httpRoutes[0]?.handler).toBe(handler); + + unregister(); + expect(registry.httpRoutes).toHaveLength(0); + }); + + it("returns noop unregister when path is missing", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + const unregister = registerPluginHttpRoute({ + path: "", + handler: vi.fn(), + registry, + accountId: "default", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(0); + expect(logs).toEqual(['plugin: webhook path missing for account "default"']); + expect(() => unregister()).not.toThrow(); + }); + + it("replaces stale route on same path and keeps latest registration", () => { + const registry = createEmptyPluginRegistry(); + const logs: string[] = []; + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + + const unregisterFirst = registerPluginHttpRoute({ + path: "/plugins/synology", + handler: firstHandler, + registry, + accountId: "default", + pluginId: "synology-chat", + log: (msg) => logs.push(msg), + }); + + const unregisterSecond = registerPluginHttpRoute({ + path: "/plugins/synology", + handler: secondHandler, + registry, + accountId: "default", + pluginId: "synology-chat", + log: (msg) => logs.push(msg), + }); + + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + expect(logs).toContain( + 'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)', + ); + + // Old unregister must not remove the replacement route. + unregisterFirst(); + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); + + unregisterSecond(); + expect(registry.httpRoutes).toHaveLength(0); + }); +}); diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index 5e2df3b522d..5987fd17370 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -29,10 +29,11 @@ export function registerPluginHttpRoute(params: { return () => {}; } - if (routes.some((entry) => entry.path === normalizedPath)) { + const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath); + if (existingIndex >= 0) { const pluginHint = params.pluginId ? ` (${params.pluginId})` : ""; - params.log?.(`plugin: webhook path ${normalizedPath} already registered${suffix}${pluginHint}`); - return () => {}; + params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`); + routes.splice(existingIndex, 1); } const entry: PluginHttpRouteRegistration = { From aea28e26fb592cf56f03bf342f640d8a707d2410 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:02:18 +0000 Subject: [PATCH 234/314] fix(auto-reply): expand standalone stop phrases --- CHANGELOG.md | 1 + docs/concepts/session.md | 2 +- docs/help/faq.md | 13 +++++++++ docs/web/control-ui.md | 2 +- src/auto-reply/reply/abort.test.ts | 45 ++++++++++++++++++++++++------ src/auto-reply/reply/abort.ts | 43 ++++++++++++++++++++++++++-- 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 036017f16e7..6df352d6c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. - Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 81550a032ed..6c9010d2c11 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -283,7 +283,7 @@ Runtime override (owner only): - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). -- Send `/stop` as a standalone message to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). +- Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). - JSONL transcripts can be opened directly to review full turns. diff --git a/docs/help/faq.md b/docs/help/faq.md index d6a5f3f1205..4cf1c7447ed 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2814,6 +2814,19 @@ Send any of these **as a standalone message** (no slash): ``` stop +stop action +stop current action +stop run +stop current run +stop agent +stop the agent +stop openclaw +openclaw stop +stop don't do anything +stop do not do anything +stop doing anything +please stop +stop please abort esc wait diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b1ff11c3243..ad6d2393523 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -99,7 +99,7 @@ Cron jobs panel notes: - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - Stop: - Click **Stop** (calls `chat.abort`) - - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band + - Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session - Abort partial retention: - When a run is aborted, partial assistant text can still be shown in the UI diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index f5bca4b677a..b36855eb80c 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -122,25 +122,52 @@ describe("abort detection", () => { expect(result.triggerBodyNormalized).toBe("/stop"); }); - it("isAbortTrigger matches bare word triggers (without slash)", () => { - expect(isAbortTrigger("stop")).toBe(true); - expect(isAbortTrigger("esc")).toBe(true); - expect(isAbortTrigger("abort")).toBe(true); - expect(isAbortTrigger("wait")).toBe(true); - expect(isAbortTrigger("exit")).toBe(true); - expect(isAbortTrigger("interrupt")).toBe(true); + it("isAbortTrigger matches standalone abort trigger phrases", () => { + const positives = [ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "please stop", + "stop please", + "STOP OPENCLAW", + "stop openclaw!!!", + "stop don’t do anything", + ]; + for (const candidate of positives) { + expect(isAbortTrigger(candidate)).toBe(true); + } + expect(isAbortTrigger("hello")).toBe(false); - // /stop is NOT matched by isAbortTrigger - it's handled separately + expect(isAbortTrigger("do not do that")).toBe(false); + // /stop is NOT matched by isAbortTrigger - it's handled separately. expect(isAbortTrigger("/stop")).toBe(false); }); it("isAbortRequestText aligns abort command semantics", () => { expect(isAbortRequestText("/stop")).toBe(true); + expect(isAbortRequestText("/stop!!!")).toBe(true); expect(isAbortRequestText("stop")).toBe(true); + expect(isAbortRequestText("stop action")).toBe(true); + expect(isAbortRequestText("stop openclaw!!!")).toBe(true); expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); - expect(isAbortRequestText("stop please")).toBe(false); + expect(isAbortRequestText("do not do that")).toBe(false); expect(isAbortRequestText("/abort")).toBe(false); }); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 4cb89483077..38bf576a435 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -23,15 +23,47 @@ import type { FinalizedMsgContext, MsgContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { clearSessionQueues } from "./queue.js"; -const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit", "interrupt"]); +const ABORT_TRIGGERS = new Set([ + "stop", + "esc", + "abort", + "wait", + "exit", + "interrupt", + "stop openclaw", + "openclaw stop", + "stop action", + "stop current action", + "stop run", + "stop current run", + "stop agent", + "stop the agent", + "stop don't do anything", + "stop dont do anything", + "stop do not do anything", + "stop doing anything", + "please stop", + "stop please", +]); const ABORT_MEMORY = new Map(); const ABORT_MEMORY_MAX = 2000; +const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"’”)\]}]+$/u; + +function normalizeAbortTriggerText(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[’`]/g, "'") + .replace(/\s+/g, " ") + .replace(TRAILING_ABORT_PUNCTUATION_RE, "") + .trim(); +} export function isAbortTrigger(text?: string): boolean { if (!text) { return false; } - const normalized = text.trim().toLowerCase(); + const normalized = normalizeAbortTriggerText(text); return ABORT_TRIGGERS.has(normalized); } @@ -43,7 +75,12 @@ export function isAbortRequestText(text?: string, options?: CommandNormalizeOpti if (!normalized) { return false; } - return normalized.toLowerCase() === "/stop" || isAbortTrigger(normalized); + const normalizedLower = normalized.toLowerCase(); + return ( + normalizedLower === "/stop" || + normalizeAbortTriggerText(normalizedLower) === "/stop" || + isAbortTrigger(normalizedLower) + ); } export function getAbortMemory(key: string): boolean | undefined { From 9d3bd50990087f7c55a060b43cfc3ac89e733027 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 10:13:00 +0800 Subject: [PATCH 235/314] fix(otel): use protobuf OTLP exporters instead of JSON/HTTP The diagnostics-otel extension validates that protocol is "http/protobuf" but was importing JSON-based `-http` exporters. This caused silent failures with backends like VictoriaMetrics that only accept protobuf-encoded OTLP. Switch all three exporter imports (metrics, traces, logs) from `@opentelemetry/exporter-*-otlp-http` to `@opentelemetry/exporter-*-otlp-proto`. Fixes #24942 Co-authored-by: Cursor (cherry picked from commit f5c0bf0497bff4c9c0748472ad5a63742af43374) --- extensions/diagnostics-otel/package.json | 6 +++--- extensions/diagnostics-otel/src/service.test.ts | 6 +++--- extensions/diagnostics-otel/src/service.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 994e9edb58a..f35358809cf 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -6,9 +6,9 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.212.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.212.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.212.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.212.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.212.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 8189ecaec8c..ab3fb57e15a 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -51,11 +51,11 @@ vi.mock("@opentelemetry/sdk-node", () => ({ }, })); -vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-metrics-otlp-proto", () => ({ OTLPMetricExporter: class {}, })); -vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-trace-otlp-proto", () => ({ OTLPTraceExporter: class { constructor(options?: unknown) { traceExporterCtor(options); @@ -63,7 +63,7 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ }, })); -vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ +vi.mock("@opentelemetry/exporter-logs-otlp-proto", () => ({ OTLPLogExporter: class {}, })); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 0749708c881..be9a547963f 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -1,8 +1,8 @@ import { metrics, trace, SpanStatusCode } from "@opentelemetry/api"; import type { SeverityNumber } from "@opentelemetry/api-logs"; -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; @@ -657,7 +657,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { }); if (logsEnabled) { - ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)"); + ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/Protobuf)"); } }, async stop() { From 8d2035633b89e560132406b90d86f5cbfff602d2 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 24 Feb 2026 10:35:10 +0800 Subject: [PATCH 236/314] fix(agents): include SOUL.md, IDENTITY.md, USER.md in subagent/cron bootstrap allowlist Subagent and isolated cron sessions only loaded AGENTS.md and TOOLS.md, causing subagents to lose their role personality, identity, and user preferences. Expand MINIMAL_BOOTSTRAP_ALLOWLIST to include the three missing identity files. Closes #24852 (cherry picked from commit c33377150eeddb42c2a24f4a48c2d01b5cdf8d3e) --- src/agents/workspace.test.ts | 51 +++++++++++++++++++ src/agents/workspace.ts | 8 ++- .../bootstrap-extra-files/handler.test.ts | 7 ++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 2fef954c1f7..0c854178917 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -11,8 +11,10 @@ import { DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, ensureAgentWorkspace, + filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles, resolveDefaultAgentWorkspaceDir, + type WorkspaceBootstrapFile, } from "./workspace.js"; describe("resolveDefaultAgentWorkspaceDir", () => { @@ -141,3 +143,52 @@ describe("loadWorkspaceBootstrapFiles", () => { expect(getMemoryEntries(files)).toHaveLength(0); }); }); + +describe("filterBootstrapFilesForSession", () => { + const mockFiles: WorkspaceBootstrapFile[] = [ + { name: "AGENTS.md", path: "/w/AGENTS.md", content: "", missing: false }, + { name: "SOUL.md", path: "/w/SOUL.md", content: "", missing: false }, + { name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false }, + { name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false }, + { name: "USER.md", path: "/w/USER.md", content: "", missing: false }, + { name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false }, + { name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false }, + { name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false }, + ]; + + it("returns all files for main session (no sessionKey)", () => { + const result = filterBootstrapFilesForSession(mockFiles); + expect(result).toHaveLength(mockFiles.length); + }); + + it("returns all files for normal (non-subagent, non-cron) session key", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:chat:main"); + expect(result).toHaveLength(mockFiles.length); + }); + + it("filters to allowlist for subagent sessions", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1"); + const names = result.map((f) => f.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); + }); + + it("filters to allowlist for cron sessions", () => { + const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check"); + const names = result.map((f) => f.name); + expect(names).toContain("AGENTS.md"); + expect(names).toContain("TOOLS.md"); + expect(names).toContain("SOUL.md"); + expect(names).toContain("IDENTITY.md"); + expect(names).toContain("USER.md"); + expect(names).not.toContain("HEARTBEAT.md"); + expect(names).not.toContain("BOOTSTRAP.md"); + expect(names).not.toContain("MEMORY.md"); + }); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index c0bd5d63386..dbef9c6517d 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -494,7 +494,13 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise { const event = createHookEvent("agent", "bootstrap", "agent:main:subagent:abc", context); await handler(event); - - expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual(["AGENTS.md", "TOOLS.md"]); + expect(context.bootstrapFiles.map((f) => f.name).toSorted()).toEqual([ + "AGENTS.md", + "SOUL.md", + "TOOLS.md", + ]); }); }); From 3129d1c489f5d2a8a9818c917aad86ae7f66eb02 Mon Sep 17 00:00:00 2001 From: Ian Eaves Date: Sun, 22 Feb 2026 16:50:06 -0600 Subject: [PATCH 237/314] fix(gateway): start browser HTTP control server module --- src/gateway/server-browser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/server-browser.ts b/src/gateway/server-browser.ts index 02f3659de3c..5f2436f431d 100644 --- a/src/gateway/server-browser.ts +++ b/src/gateway/server-browser.ts @@ -11,7 +11,7 @@ export async function startBrowserControlServerIfEnabled(): Promise Date: Tue, 24 Feb 2026 04:05:31 +0000 Subject: [PATCH 238/314] docs(changelog): note browser control startup import fix (#23974) (thanks @ieaves) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df352d6c8f..da864a8531e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. From dd41a784586c8030ca19a0f3fd8bcd626fc7b824 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Mon, 23 Feb 2026 11:07:49 -0300 Subject: [PATCH 239/314] fix(bluebubbles): pass SSRF policy for localhost attachment downloads (#24457) (cherry picked from commit aff64567c757ac46aad320b53406b5036361ff65) --- extensions/bluebubbles/src/account-resolve.ts | 8 +++- .../bluebubbles/src/attachments.test.ts | 43 +++++++++++++++++++ extensions/bluebubbles/src/attachments.ts | 3 +- extensions/bluebubbles/src/config-schema.ts | 1 + extensions/bluebubbles/src/types.ts | 2 + 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts index 0ec539644fe..904d21d4d3f 100644 --- a/extensions/bluebubbles/src/account-resolve.ts +++ b/extensions/bluebubbles/src/account-resolve.ts @@ -12,6 +12,7 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv baseUrl: string; password: string; accountId: string; + allowPrivateNetwork: boolean; } { const account = resolveBlueBubblesAccount({ cfg: params.cfg ?? {}, @@ -25,5 +26,10 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv if (!password) { throw new Error("BlueBubbles password is required"); } - return { baseUrl, password, accountId: account.accountId }; + return { + baseUrl, + password, + accountId: account.accountId, + allowPrivateNetwork: account.config.allowPrivateNetwork === true, + }; } diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 7ebab0485df..d6b12d311f8 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -268,6 +268,49 @@ describe("downloadBlueBubblesAttachment", () => { expect(calledUrl).toContain("password=config-password"); expect(result.buffer).toEqual(new Uint8Array([1])); }); + + it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test", + allowPrivateNetwork: true, + }, + }, + }, + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + }); + + it("does not pass ssrfPolicy when allowPrivateNetwork is not set", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record; + expect(fetchMediaArgs.ssrfPolicy).toBeUndefined(); + }); }); describe("sendBlueBubblesAttachment", () => { diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 3b8850f2154..6ccb043845f 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -82,7 +82,7 @@ export async function downloadBlueBubblesAttachment( if (!guid) { throw new Error("BlueBubbles attachment guid is required"); } - const { baseUrl, password } = resolveAccount(opts); + const { baseUrl, password, allowPrivateNetwork } = resolveAccount(opts); const url = buildBlueBubblesApiUrl({ baseUrl, path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, @@ -94,6 +94,7 @@ export async function downloadBlueBubblesAttachment( url, filePathHint: attachment.transferName ?? attachment.guid ?? "attachment", maxBytes, + ssrfPolicy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined, fetchImpl: async (input, init) => await blueBubblesFetchWithTimeout( resolveRequestUrl(input), diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index b575ab85fe1..e4bef3fd73b 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -43,6 +43,7 @@ const bluebubblesAccountSchema = z mediaMaxMb: z.number().int().positive().optional(), mediaLocalRoots: z.array(z.string()).optional(), sendReadReceipts: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), blockStreaming: z.boolean().optional(), groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), }) diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 7346c4ff42a..72ccd991857 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -53,6 +53,8 @@ export type BlueBubblesAccountConfig = { mediaLocalRoots?: string[]; /** Send read receipts for incoming messages (default: true). */ sendReadReceipts?: boolean; + /** Allow fetching from private/internal IP addresses (e.g. localhost). Required for same-host BlueBubbles setups. */ + allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; }; From 721d8b2278fe578a5ee49f4bbd1da1bbcc9578d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:10:52 +0000 Subject: [PATCH 240/314] test(discord): stabilize parent-info + doctor migration assertions (#25028) --- ...-routing-allowfrom-channels-whatsapp-allowfrom.test.ts | 8 +++++--- src/discord/monitor/threading.parent-info.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts index 95fe4be23f4..4cece369684 100644 --- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts +++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts @@ -54,9 +54,11 @@ describe("doctor command", () => { const remote = gateway.remote as Record; const channels = (written.channels as Record) ?? {}; - expect(channels.whatsapp).toEqual({ - allowFrom: ["+15555550123"], - }); + expect(channels.whatsapp).toEqual( + expect.objectContaining({ + allowFrom: ["+15555550123"], + }), + ); expect(written.routing).toBeUndefined(); expect(remote.token).toBe("legacy-remote-token"); expect(auth).toBeUndefined(); diff --git a/src/discord/monitor/threading.parent-info.test.ts b/src/discord/monitor/threading.parent-info.test.ts index 1954dd4fe9d..6d2d169002c 100644 --- a/src/discord/monitor/threading.parent-info.test.ts +++ b/src/discord/monitor/threading.parent-info.test.ts @@ -1,8 +1,13 @@ import { ChannelType } from "@buape/carbon"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; describe("resolveDiscordThreadParentInfo", () => { + beforeEach(() => { + __resetDiscordChannelInfoCacheForTest(); + }); + it("falls back to fetched thread parentId when parentId is missing in payload", async () => { const fetchChannel = vi.fn(async (channelId: string) => { if (channelId === "thread-1") { From 67bac62c2c7065bdbe4a51beb9ac3f6596c40aca Mon Sep 17 00:00:00 2001 From: NK Date: Tue, 17 Feb 2026 20:29:07 -0800 Subject: [PATCH 241/314] fix: Chrome relay extension auto-reattach after SPA navigation When Chrome's debugger detaches during page navigation (common in SPAs like Gmail, Google Calendar), the extension now automatically re-attaches instead of permanently losing the connection. Changes: - onDebuggerDetach: detect navigation vs tab close, attempt re-attach with 3 retries and exponential backoff (300ms, 700ms, 1500ms) - Add reattachPending guard to prevent concurrent re-attach races - connectOrToggleForActiveTab: handle pending re-attach state - onRelayClosed: clear reattachPending on relay disconnect - Add chrome.tabs.onRemoved listener for proper cleanup Fixes #19744 --- assets/chrome-extension/background.js | 138 ++++++++++++++++++++------ 1 file changed, 105 insertions(+), 33 deletions(-) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index b149f8745dc..294d9f87b2b 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -30,6 +30,10 @@ const pending = new Map() /** @type {Set} */ const tabOperationLocks = new Set() +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + // Reconnect state for exponential backoff. let reconnectAttempt = 0 let reconnectTimer = null @@ -190,6 +194,8 @@ function onRelayClosed(reason) { p.reject(new Error(`Relay disconnected (${reason})`)) } + reattachPending.clear() + for (const [tabId, tab] of tabs.entries()) { if (tab.state === 'connected') { setBadge(tabId, 'connecting') @@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() { tabOperationLocks.add(tabId) try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + const existing = tabs.get(tabId) if (existing?.state === 'connected') { await detachTab(tabId, 'toggle') @@ -632,50 +648,106 @@ function onDebuggerEvent(source, method, params) { } } -// Navigation/reload fires target_closed but the tab is still alive — Chrome -// just swaps the renderer process. Suppress the detach event to the relay and -// seamlessly re-attach after a short grace period. -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - if (reason === 'target_closed') { - const oldState = tabs.get(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) - - setTimeout(async () => { - try { - // If user manually detached during the grace period, bail out. - if (!tabs.has(tabId)) return - const tab = await chrome.tabs.get(tabId) - if (tab && relayWs?.readyState === WebSocket.OPEN) { - console.log(`Re-attaching tab ${tabId} after navigation`) - if (oldState?.sessionId) tabBySession.delete(oldState.sessionId) - tabs.delete(tabId) - await attachTab(tabId, { skipAttachedEvent: false }) - } else { - // Tab gone or relay down — full cleanup. - void detachTab(tabId, reason) - } - } catch (err) { - console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message) - void detachTab(tabId, reason) - } - }, 500) + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) return } - // Non-navigation detach (user action, crash, etc.) — full cleanup. - void detachTab(tabId, reason) + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } // Tab lifecycle listeners — clean up stale entries. chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) if (!tabs.has(tabId)) return const tab = tabs.get(tabId) if (tab?.sessionId) tabBySession.delete(tab.sessionId) From 7c028e8c09bbc380e6513444e7a174697a9cb83a Mon Sep 17 00:00:00 2001 From: NK Date: Tue, 17 Feb 2026 22:04:16 -0800 Subject: [PATCH 242/314] fix: respect canceled_by_user and replaced_with_devtools detach reasons Skip re-attach when user explicitly dismisses debugger bar or opens DevTools. Prevents frustrating re-attach loop that fights user intent. Addresses review feedback from greptile-apps. --- assets/chrome-extension/background.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 294d9f87b2b..5ebe4008af3 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -653,15 +653,18 @@ async function onDebuggerDetach(source, reason) { if (!tabId) return if (!tabs.has(tabId)) return + // User explicitly cancelled or DevTools replaced the connection — respect their intent if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { void detachTab(tabId, reason) return } + // Check if tab still exists — distinguishes navigation from tab close let tabInfo try { tabInfo = await chrome.tabs.get(tabId) } catch { + // Tab is gone (closed) — normal cleanup void detachTab(tabId, reason) return } From 004a61056c522de336fa5f0792988af390eafaef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:11:01 +0000 Subject: [PATCH 243/314] docs(changelog): note relay nav auto-reattach fix (#19766) (thanks @nishantkabra77) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da864a8531e..9307e7437fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. +- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. From 3eabd538980e6695b0d58e0be565a5a56efc6658 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:50:45 +0530 Subject: [PATCH 244/314] Tests: add regressions for subagent completion fallback and explicit direct route --- src/agents/subagent-announce.format.test.ts | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index a612e9fca02..b486dff75c8 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -993,6 +993,77 @@ describe("subagent announce formatting", () => { }); }); + it("falls back to internal requester-session injection when completion route is missing", async () => { + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:main": { + sessionId: "requester-session-no-route", + }, + }; + agentSpy.mockImplementationOnce(async (req: AgentCallRequest) => { + const deliver = req.params?.deliver; + const channel = req.params?.channel; + if (deliver === true && typeof channel !== "string") { + throw new Error("Channel is required when deliver=true"); + } + return { runId: "run-main", status: "ok" }; + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-missing-route", + requesterSessionKey: "main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(0); + expect(agentSpy).toHaveBeenCalledTimes(1); + expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "agent", + params: { + sessionKey: "agent:main:main", + deliver: false, + }, + }); + }); + + it("uses direct completion delivery when explicit channel+to route is available", async () => { + sessionStore = { + "agent:main:main": { + sessionId: "requester-session-direct-route", + }, + }; + agentSpy.mockImplementationOnce(async () => { + throw new Error("agent fallback should not run when direct route exists"); + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-explicit-route", + requesterSessionKey: "main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).toHaveBeenCalledTimes(0); + expect(sendSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "send", + params: { + sessionKey: "agent:main:main", + channel: "discord", + to: "channel:12345", + }, + }); + }); + it("returns failure for completion-mode when direct delivery fails and queue fallback is unavailable", async () => { embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); From 28d658e178fed3ece5225b8465291fb1db1ca1f2 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:51:18 +0530 Subject: [PATCH 245/314] Tests: verify tools invoke propagates route headers for subagent spawn context --- src/gateway/tools-invoke-http.test.ts | 44 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 3a2ec73607b..f87f00593a0 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; let cfg: Record = {}; +let lastCreateOpenClawToolsContext: Record | undefined; // Perf: keep this suite pure unit. Mock heavyweight config/session modules. vi.mock("../config/config.js", () => ({ @@ -78,7 +79,13 @@ vi.mock("../agents/openclaw-tools.js", () => { { name: "sessions_spawn", parameters: { type: "object", properties: {} }, - execute: async () => ({ ok: true }), + execute: async () => ({ + ok: true, + route: { + agentTo: lastCreateOpenClawToolsContext?.agentTo, + agentThreadId: lastCreateOpenClawToolsContext?.agentThreadId, + }, + }), }, { name: "sessions_send", @@ -119,7 +126,10 @@ vi.mock("../agents/openclaw-tools.js", () => { ]; return { - createOpenClawTools: () => tools, + createOpenClawTools: (ctx: Record) => { + lastCreateOpenClawToolsContext = ctx; + return tools; + }, }; }); @@ -176,6 +186,7 @@ beforeEach(() => { delete process.env.OPENCLAW_GATEWAY_PASSWORD; pluginHttpHandlers = []; cfg = {}; + lastCreateOpenClawToolsContext = undefined; }); const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN; @@ -365,6 +376,35 @@ describe("POST /tools/invoke", () => { expect(body.error.type).toBe("not_found"); }); + it("propagates message target/thread headers into tools context for sessions_spawn", async () => { + cfg = { + ...cfg, + agents: { + list: [{ id: "main", default: true, tools: { allow: ["sessions_spawn"] } }], + }, + gateway: { tools: { allow: ["sessions_spawn"] } }, + }; + + const res = await invokeTool({ + port: sharedPort, + headers: { + ...gatewayAuthHeaders(), + "x-openclaw-message-to": "channel:24514", + "x-openclaw-thread-id": "thread-24514", + }, + tool: "sessions_spawn", + sessionKey: "main", + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.result?.route).toEqual({ + agentTo: "channel:24514", + agentThreadId: "thread-24514", + }); + }); + it("denies sessions_send via HTTP gateway", async () => { cfg = { ...cfg, From f9ffd41cfac57ce6bcaf7b2262437b2ddf79c90a Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:51:28 +0530 Subject: [PATCH 246/314] Subagents: fallback completion announce to internal session when outbound route is incomplete --- src/agents/subagent-announce.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index b794824ebae..27176029fc4 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -731,6 +731,16 @@ async function sendSubagentAnnounceDirectly(params: { } const directOrigin = normalizeDeliveryContext(params.directOrigin); + const directChannelRaw = + typeof directOrigin?.channel === "string" ? directOrigin.channel.trim() : ""; + const directChannel = + directChannelRaw && isDeliverableMessageChannel(directChannelRaw) ? directChannelRaw : ""; + const directTo = typeof directOrigin?.to === "string" ? directOrigin.to.trim() : ""; + const hasDeliverableDirectTarget = + !params.requesterIsSubagent && Boolean(directChannel) && Boolean(directTo); + const shouldDeliverExternally = + !params.requesterIsSubagent && + (!params.expectsCompletionMessage || hasDeliverableDirectTarget); const threadId = directOrigin?.threadId != null && directOrigin.threadId !== "" ? String(directOrigin.threadId) @@ -746,12 +756,12 @@ async function sendSubagentAnnounceDirectly(params: { params: { sessionKey: canonicalRequesterSessionKey, message: params.triggerMessage, - deliver: !params.requesterIsSubagent, + deliver: shouldDeliverExternally, bestEffortDeliver: params.bestEffortDeliver, - channel: params.requesterIsSubagent ? undefined : directOrigin?.channel, - accountId: params.requesterIsSubagent ? undefined : directOrigin?.accountId, - to: params.requesterIsSubagent ? undefined : directOrigin?.to, - threadId: params.requesterIsSubagent ? undefined : threadId, + channel: shouldDeliverExternally ? directChannel : undefined, + accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined, + to: shouldDeliverExternally ? directTo : undefined, + threadId: shouldDeliverExternally ? threadId : undefined, idempotencyKey: params.directIdempotencyKey, }, expectFinal: true, From 8796c78b3d64fc932331bd3bdee0a1bf8d5c79a3 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:51:59 +0530 Subject: [PATCH 247/314] Gateway: propagate message target and thread headers into tools invoke context --- src/gateway/tools-invoke-http.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 0be53d5fc4e..caf71c56c3c 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -213,6 +213,8 @@ export async function handleToolsInvokeHttpRequest( getHeader(req, "x-openclaw-message-channel") ?? "", ); const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined; + const agentTo = getHeader(req, "x-openclaw-message-to")?.trim() || undefined; + const agentThreadId = getHeader(req, "x-openclaw-thread-id")?.trim() || undefined; const { agentId, @@ -248,6 +250,8 @@ export async function handleToolsInvokeHttpRequest( agentSessionKey: sessionKey, agentChannel: messageChannel ?? undefined, agentAccountId: accountId, + agentTo, + agentThreadId, config: cfg, pluginToolAllowlist: collectExplicitAllowlist([ profilePolicy, From 420d8c663c6cc4994a1c9cbcd48c3ddcc2f884f0 Mon Sep 17 00:00:00 2001 From: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:52:16 +0530 Subject: [PATCH 248/314] Tests/Typing: stabilize subagent completion routing changes --- ...aw-tools.subagents.sessions-spawn.lifecycle.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 5a883c7c6c4..77b948ea5af 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -245,7 +245,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { } | undefined; expect(second?.sessionKey).toBe("agent:main:discord:group:req"); - expect(second?.deliver).toBe(true); + expect(second?.deliver).toBe(false); expect(second?.message).toContain("subagent task"); const sendCalls = ctx.calls.filter((c) => c.method === "send"); @@ -297,7 +297,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { // Second call: main agent trigger const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; expect(second?.sessionKey).toBe("agent:main:discord:group:req"); - expect(second?.deliver).toBe(true); + expect(second?.deliver).toBe(false); // No direct send to external channel (main agent handles delivery) const sendCalls = ctx.calls.filter((c) => c.method === "send"); @@ -365,8 +365,8 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { const announceParams = agentCalls[1]?.params as | { accountId?: string; channel?: string; deliver?: boolean } | undefined; - expect(announceParams?.deliver).toBe(true); - expect(announceParams?.channel).toBe("whatsapp"); - expect(announceParams?.accountId).toBe("kev"); + expect(announceParams?.deliver).toBe(false); + expect(announceParams?.channel).toBeUndefined(); + expect(announceParams?.accountId).toBeUndefined(); }); }); From b2719d00ff6c92a3b0b4841dc8d7e0270d4a242c Mon Sep 17 00:00:00 2001 From: Keith Date: Sun, 22 Feb 2026 17:06:06 +1300 Subject: [PATCH 249/314] fix(subagents): restore isInternalMessageChannel guard in resolveAnnounceOrigin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the narrower internal-channel guard from PR #22223 (fe57bea08) that was inadvertently reverted by f555835b0. The original !isDeliverableMessageChannel() check strips the requester's channel whenever it is not in the registered deliverable set. This causes delivery failures for plugin channels whose adapter ID differs from their plugin ID (e.g. "gmail" vs "openclaw-gmail"): the requester origin is discarded and the announce falls back to stale session routes — typically WhatsApp — resulting in a timeout followed by an E.164 format error. Replacing with isInternalMessageChannel() limits stripping to explicitly internal channels (webchat), preserving the requester origin for all external channels regardless of whether they are currently in the deliverable list. Fixes: #22223 regression introduced in f555835b0 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/agents/subagent-announce.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 27176029fc4..c0c981e8e3f 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -21,7 +21,7 @@ import { mergeDeliveryContext, normalizeDeliveryContext, } from "../utils/delivery-context.js"; -import { isDeliverableMessageChannel } from "../utils/message-channel.js"; +import { isDeliverableMessageChannel, isInternalMessageChannel } from "../utils/message-channel.js"; import { buildAnnounceIdFromChildRun, buildAnnounceIdempotencyKey, @@ -350,9 +350,12 @@ function resolveAnnounceOrigin( ): DeliveryContext | undefined { const normalizedRequester = normalizeDeliveryContext(requesterOrigin); const normalizedEntry = deliveryContextFromSession(entry); - if (normalizedRequester?.channel && !isDeliverableMessageChannel(normalizedRequester.channel)) { - // Ignore internal/non-deliverable channel hints (for example webchat) - // so a valid persisted route can still be used for outbound delivery. + if (normalizedRequester?.channel && isInternalMessageChannel(normalizedRequester.channel)) { + // Ignore internal channel hints (webchat) so a valid persisted route + // can still be used for outbound delivery. Non-standard channels that + // are not in the deliverable list should NOT be stripped here — doing + // so causes the session entry's stale lastChannel (often WhatsApp) to + // override the actual requester origin, leading to delivery failures. return mergeDeliveryContext( { accountId: normalizedRequester.accountId, From 1fdaaaedd36410f43437821941a16fa213eef831 Mon Sep 17 00:00:00 2001 From: Kriz Poon Date: Fri, 20 Feb 2026 14:09:37 +0000 Subject: [PATCH 250/314] Docs: clarify Chrome extension relay port derivation (gateway + 3) --- docs/tools/chrome-extension.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 6049dfb36a7..964eb40f37b 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -77,6 +77,18 @@ openclaw browser create-profile \ --color "#00AA00" ``` +### Custom Gateway ports + +If you're using a custom gateway port, the extension relay port is automatically derived: + +**Extension Relay Port = Gateway Port + 3** + +Example: if `gateway.port: 19001`, then: + +- Extension relay port: `19004` (gateway + 3) + +Configure the extension to use the derived relay port in the extension Options page. + ## Attach / detach (toolbar button) - Open the tab you want OpenClaw to control. From 0a53a77dd6f29bf8c65ec030b6282d45b013da4f Mon Sep 17 00:00:00 2001 From: Kriz Poon Date: Fri, 20 Feb 2026 15:31:17 +0000 Subject: [PATCH 251/314] Chrome extension: validate relay endpoint response format Options page now validates that /json/version returns valid CDP JSON (with Browser/Protocol-Version fields) rather than accepting any HTTP 200 response. This prevents false success when users mistakenly configure the gateway port instead of the relay port (gateway + 3). Helpful error messages now guide users to use "gateway port + 3" when they configure the wrong port. --- assets/chrome-extension/background.js | 13 +++++++++- assets/chrome-extension/options.js | 37 +++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 5ebe4008af3..94956571101 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -882,7 +882,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const { url, token } = msg const headers = token ? { 'x-openclaw-relay-token': token } : {} fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) - .then((res) => sendResponse({ status: res.status, ok: res.ok })) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) return true }) diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 7a47a5d947e..96b87768dae 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -54,12 +54,39 @@ async function checkRelayReachable(port, token) { } if (res.error) throw new Error(res.error) if (!res.ok) throw new Error(`HTTP ${res.status}`) + + // Validate that this is a CDP relay /json/version payload, not gateway HTML. + const contentType = String(res.contentType || '') + const data = res.json + if (!contentType.includes('application/json')) { + setStatus( + 'error', + 'Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + return + } + if (!data || typeof data !== 'object' || !('Browser' in data) || !('Protocol-Version' in data)) { + setStatus( + 'error', + 'Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + return + } + setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) - } catch { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) + } catch (err) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + setStatus( + 'error', + 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + } else { + setStatus( + 'error', + `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + ) + } } } From b7949d317fb2bdec77420e61d0d5818d303134f3 Mon Sep 17 00:00:00 2001 From: Kriz Poon Date: Fri, 20 Feb 2026 23:28:15 +0000 Subject: [PATCH 252/314] Chrome extension: simplify validation logic Use OR operator to require both Browser and Protocol-Version fields. Simplified catch block to generic error message since specific wrong-port cases are already handled by the validation blocks above. --- assets/chrome-extension/options.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 96b87768dae..d2d9a198a3b 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -87,6 +87,19 @@ async function checkRelayReachable(port, token) { `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) } + } catch (err) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + setStatus( + 'error', + 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', + ) + } else { + setStatus( + 'error', + `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + ) + } } } From 1237516ae892847bacef95d835b1743f9d66b3f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:14:43 +0000 Subject: [PATCH 253/314] fix(chrome-extension): finalize relay endpoint validation flow (#22252) (thanks @krizpoon) --- CHANGELOG.md | 1 + assets/chrome-extension/options.js | 13 ------------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9307e7437fd..358264587da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. - Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. +- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. - Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index d2d9a198a3b..96b87768dae 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -87,19 +87,6 @@ async function checkRelayReachable(port, token) { `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) } - } catch (err) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - setStatus( - 'error', - 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - } else { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } } } From f6b4baa776d4b0450ca444ebf8e6f2773b9a256c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:16:17 +0000 Subject: [PATCH 254/314] test(telegram): align stop-phrase sequential key expectation (#25034) --- src/telegram/bot.create-telegram-bot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index fdd2eb32ecc..f5c4735ea75 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -183,7 +183,7 @@ describe("createTelegramBot", () => { getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }), }), - ).toBe("telegram:123"); + ).toBe("telegram:123:control"); expect( getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }), From 87dd89696357d02ec0f36b1ef90b44732890395a Mon Sep 17 00:00:00 2001 From: Slats <42514321+Slats24@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:38:20 +0000 Subject: [PATCH 255/314] fix: sessions_sspawn model override ignored for sub-agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix bug where sessions_spawn model parameter was ignored, causing sub-agents to always use the parent's default model. The allowAny flag from buildAllowedModelSet() was not being captured or used. 🤖 AI-assisted (Claude) - fully tested locally Fixes #17479, #6295, #10963 --- src/commands/agent.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7ca8591faa4..ca4e42d314b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -390,6 +390,7 @@ export async function agentCommand( let allowedModelKeys = new Set(); let allowedModelCatalog: Awaited> = []; let modelCatalog: Awaited> | null = null; + let allowAnyModel = false; if (needsModelCatalog) { modelCatalog = await loadModelCatalog({ config: cfg }); @@ -401,6 +402,7 @@ export async function agentCommand( }); allowedModelKeys = allowed.allowedKeys; allowedModelCatalog = allowed.allowedCatalog; + allowAnyModel = allowed.allowAny ?? false; } if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) { @@ -412,7 +414,7 @@ export async function agentCommand( const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if ( !isCliProvider(normalizedOverride.provider, cfg) && - allowedModelKeys.size > 0 && + !allowAnyModel && !allowedModelKeys.has(key) ) { const { updated } = applyModelOverrideToSessionEntry({ @@ -439,7 +441,7 @@ export async function agentCommand( const key = modelKey(normalizedStored.provider, normalizedStored.model); if ( isCliProvider(normalizedStored.provider, cfg) || - allowedModelKeys.size === 0 || + allowAnyModel || allowedModelKeys.has(key) ) { provider = normalizedStored.provider; From cd3927ad67ae9328eac067930e068c2b8bd06dec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:15:18 +0000 Subject: [PATCH 256/314] fix(sessions): preserve allow-any subagent model overrides (#21088) (thanks @Slats24) --- CHANGELOG.md | 1 + src/commands/agent.test.ts | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 358264587da..008661d60ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 3e26ec3ec00..0118e076365 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -367,6 +367,48 @@ describe("agentCommand", () => { }); }); + it("keeps stored session model override when models allowlist is empty", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + writeSessionStoreSeed(store, { + "agent:main:subagent:allow-any": { + sessionId: "session-allow-any", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-custom-foo", + }, + }); + + mockConfig(home, store, { + model: { primary: "anthropic/claude-opus-4-5" }, + models: {}, + }); + + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { id: "claude-opus-4-5", name: "Opus", provider: "anthropic" }, + ]); + + await agentCommand( + { + message: "hi", + sessionKey: "agent:main:subagent:allow-any", + }, + runtime, + ); + + const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; + expect(callArgs?.provider).toBe("openai"); + expect(callArgs?.model).toBe("gpt-custom-foo"); + + const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record< + string, + { providerOverride?: string; modelOverride?: string } + >; + expect(saved["agent:main:subagent:allow-any"]?.providerOverride).toBe("openai"); + expect(saved["agent:main:subagent:allow-any"]?.modelOverride).toBe("gpt-custom-foo"); + }); + }); + it("keeps explicit sessionKey even when sessionId exists elsewhere", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); From c3b3065cc9346e3290d0ae60078237c65d3a02bf Mon Sep 17 00:00:00 2001 From: HeMuling <74801533+HeMuling@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:42:22 +0800 Subject: [PATCH 257/314] fix(subagents): reconcile orphaned restored runs --- ...agent-registry.announce-loop-guard.test.ts | 6 +- .../subagent-registry.persistence.test.ts | 152 +++++++++++++++++- src/agents/subagent-registry.ts | 140 ++++++++++++++++ 3 files changed, 296 insertions(+), 2 deletions(-) diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 5a2bfb2dbec..8389c53503c 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -16,7 +16,11 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("../config/sessions.js", () => ({ - loadSessionStore: () => ({}), + loadSessionStore: () => ({ + "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, + "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, + "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, + }), resolveAgentIdFromSessionKey: (key: string) => { const match = key.match(/^agent:([^:]+)/); return match?.[1] ?? "main"; diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 9ef2458e35c..5558d77785e 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; import { captureEnv } from "../test-utils/env.js"; import { + addSubagentRunForTests, + clearSubagentRunSteerRestart, initSubagentRegistry, + listSubagentRunsForRequester, registerSubagentRun, resetSubagentRegistryForTests, } from "./subagent-registry.js"; @@ -22,12 +25,93 @@ describe("subagent registry persistence", () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; - const writePersistedRegistry = async (persisted: Record) => { + const resolveAgentIdFromSessionKey = (sessionKey: string) => { + const match = sessionKey.match(/^agent:([^:]+):/i); + return (match?.[1] ?? "main").trim().toLowerCase() || "main"; + }; + + const resolveSessionStorePath = (stateDir: string, agentId: string) => + path.join(stateDir, "agents", agentId, "sessions", "sessions.json"); + + const readSessionStore = async (storePath: string) => { + try { + const raw = await fs.readFile(storePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record>; + } + } catch { + // ignore + } + return {} as Record>; + }; + + const writeChildSessionEntry = async (params: { + sessionKey: string; + sessionId?: string; + updatedAt?: number; + }) => { + if (!tempStateDir) { + throw new Error("tempStateDir not initialized"); + } + const agentId = resolveAgentIdFromSessionKey(params.sessionKey); + const storePath = resolveSessionStorePath(tempStateDir, agentId); + const store = await readSessionStore(storePath); + store[params.sessionKey] = { + ...(store[params.sessionKey] ?? {}), + sessionId: params.sessionId ?? `sess-${agentId}-${Date.now()}`, + updatedAt: params.updatedAt ?? Date.now(), + }; + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, `${JSON.stringify(store)}\n`, "utf8"); + return storePath; + }; + + const removeChildSessionEntry = async (sessionKey: string) => { + if (!tempStateDir) { + throw new Error("tempStateDir not initialized"); + } + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const storePath = resolveSessionStorePath(tempStateDir, agentId); + const store = await readSessionStore(storePath); + delete store[sessionKey]; + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, `${JSON.stringify(store)}\n`, "utf8"); + return storePath; + }; + + const seedChildSessionsForPersistedRuns = async (persisted: Record) => { + const runs = (persisted.runs ?? {}) as Record< + string, + { + runId?: string; + childSessionKey?: string; + } + >; + for (const [runId, run] of Object.entries(runs)) { + const childSessionKey = run?.childSessionKey?.trim(); + if (!childSessionKey) { + continue; + } + await writeChildSessionEntry({ + sessionKey: childSessionKey, + sessionId: `sess-${run.runId ?? runId}`, + }); + } + }; + + const writePersistedRegistry = async ( + persisted: Record, + opts?: { seedChildSessions?: boolean }, + ) => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); process.env.OPENCLAW_STATE_DIR = tempStateDir; const registryPath = path.join(tempStateDir, "subagents", "runs.json"); await fs.mkdir(path.dirname(registryPath), { recursive: true }); await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + if (opts?.seedChildSessions !== false) { + await seedChildSessionsForPersistedRuns(persisted); + } return registryPath; }; @@ -90,6 +174,10 @@ describe("subagent registry persistence", () => { task: "do the thing", cleanup: "keep", }); + await writeChildSessionEntry({ + sessionKey: "agent:main:subagent:test", + sessionId: "sess-test", + }); const registryPath = path.join(tempStateDir, "subagents", "runs.json"); const raw = await fs.readFile(registryPath, "utf8"); @@ -162,6 +250,10 @@ describe("subagent registry persistence", () => { }; await fs.mkdir(path.dirname(registryPath), { recursive: true }); await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8"); + await writeChildSessionEntry({ + sessionKey: "agent:main:subagent:two", + sessionId: "sess-two", + }); resetSubagentRegistryForTests({ persist: false }); initSubagentRegistry(); @@ -268,6 +360,64 @@ describe("subagent registry persistence", () => { expect(afterSecond.runs?.["run-4"]).toBeUndefined(); }); + it("reconciles orphaned restored runs by pruning them from registry", async () => { + const persisted = createPersistedEndedRun({ + runId: "run-orphan-restore", + childSessionKey: "agent:main:subagent:ghost-restore", + task: "orphan restore", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted, { + seedChildSessions: false, + }); + + await restartRegistryAndFlush(); + + expect(announceSpy).not.toHaveBeenCalled(); + const after = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs?: Record; + }; + expect(after.runs?.["run-orphan-restore"]).toBeUndefined(); + expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); + }); + + it("resume guard prunes orphan runs before announce retry", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-")); + process.env.OPENCLAW_STATE_DIR = tempStateDir; + const runId = "run-orphan-resume-guard"; + const childSessionKey = "agent:main:subagent:ghost-resume"; + const now = Date.now(); + + await writeChildSessionEntry({ + sessionKey: childSessionKey, + sessionId: "sess-resume-guard", + updatedAt: now, + }); + addSubagentRunForTests({ + runId, + childSessionKey, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "resume orphan guard", + cleanup: "keep", + createdAt: now - 50, + startedAt: now - 25, + endedAt: now, + suppressAnnounceReason: "steer-restart", + cleanupHandled: false, + }); + await removeChildSessionEntry(childSessionKey); + + const changed = clearSubagentRunSteerRestart(runId); + expect(changed).toBe(true); + await flushQueuedRegistryWork(); + + expect(announceSpy).not.toHaveBeenCalled(); + expect(listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); + const persisted = loadSubagentRegistryFromDisk(); + expect(persisted.has(runId)).toBe(false); + }); + it("uses isolated temp state when OPENCLAW_STATE_DIR is unset in tests", async () => { delete process.env.OPENCLAW_STATE_DIR; vi.resetModules(); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 8506b77d53e..edb8f228b07 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1,4 +1,10 @@ import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveStorePath, + type SessionEntry, +} from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { defaultRuntime } from "../runtime.js"; @@ -59,6 +65,7 @@ const MAX_ANNOUNCE_RETRY_COUNT = 3; * succeeded. Guards against stale registry entries surviving gateway restarts. */ const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes +type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id"; function resolveAnnounceRetryDelayMs(retryCount: number) { const boundedRetryCount = Math.max(0, Math.min(retryCount, 10)); @@ -82,6 +89,119 @@ function persistSubagentRuns() { persistSubagentRunsToDisk(subagentRuns); } +function findSessionEntryByKey(store: Record, sessionKey: string) { + const direct = store[sessionKey]; + if (direct) { + return direct; + } + const normalized = sessionKey.toLowerCase(); + for (const [key, entry] of Object.entries(store)) { + if (key.toLowerCase() === normalized) { + return entry; + } + } + return undefined; +} + +function resolveSubagentRunOrphanReason(params: { + entry: SubagentRunRecord; + storeCache?: Map>; +}): SubagentRunOrphanReason | null { + const childSessionKey = params.entry.childSessionKey?.trim(); + if (!childSessionKey) { + return "missing-session-entry"; + } + try { + const cfg = loadConfig(); + const agentId = resolveAgentIdFromSessionKey(childSessionKey); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + let store = params.storeCache?.get(storePath); + if (!store) { + store = loadSessionStore(storePath); + params.storeCache?.set(storePath, store); + } + const sessionEntry = findSessionEntryByKey(store, childSessionKey); + if (!sessionEntry) { + return "missing-session-entry"; + } + if (typeof sessionEntry.sessionId !== "string" || !sessionEntry.sessionId.trim()) { + return "missing-session-id"; + } + return null; + } catch { + // Best-effort guard: avoid false orphan pruning on transient read/config failures. + return null; + } +} + +function reconcileOrphanedRun(params: { + runId: string; + entry: SubagentRunRecord; + reason: SubagentRunOrphanReason; + source: "restore" | "resume"; +}) { + const now = Date.now(); + let changed = false; + if (typeof params.entry.endedAt !== "number") { + params.entry.endedAt = now; + changed = true; + } + const orphanOutcome: SubagentRunOutcome = { + status: "error", + error: `orphaned subagent run (${params.reason})`, + }; + if (!runOutcomesEqual(params.entry.outcome, orphanOutcome)) { + params.entry.outcome = orphanOutcome; + changed = true; + } + if (params.entry.endedReason !== SUBAGENT_ENDED_REASON_ERROR) { + params.entry.endedReason = SUBAGENT_ENDED_REASON_ERROR; + changed = true; + } + if (params.entry.cleanupHandled !== true) { + params.entry.cleanupHandled = true; + changed = true; + } + if (typeof params.entry.cleanupCompletedAt !== "number") { + params.entry.cleanupCompletedAt = now; + changed = true; + } + const removed = subagentRuns.delete(params.runId); + resumedRuns.delete(params.runId); + if (!removed && !changed) { + return false; + } + defaultRuntime.log( + `[warn] Subagent orphan run pruned source=${params.source} run=${params.runId} child=${params.entry.childSessionKey} reason=${params.reason}`, + ); + return true; +} + +function reconcileOrphanedRestoredRuns() { + const storeCache = new Map>(); + let changed = false; + for (const [runId, entry] of subagentRuns.entries()) { + const orphanReason = resolveSubagentRunOrphanReason({ + entry, + storeCache, + }); + if (!orphanReason) { + continue; + } + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "restore", + }) + ) { + changed = true; + } + } + return changed; +} + const resumedRuns = new Set(); const endedHookInFlightRunIds = new Set(); @@ -225,6 +345,20 @@ function resumeSubagentRun(runId: string) { if (!entry) { return; } + const orphanReason = resolveSubagentRunOrphanReason({ entry }); + if (orphanReason) { + if ( + reconcileOrphanedRun({ + runId, + entry, + reason: orphanReason, + source: "resume", + }) + ) { + persistSubagentRuns(); + } + return; + } if (entry.cleanupCompletedAt) { return; } @@ -290,6 +424,12 @@ function restoreSubagentRunsOnce() { if (restoredCount === 0) { return; } + if (reconcileOrphanedRestoredRuns()) { + persistSubagentRuns(); + } + if (subagentRuns.size === 0) { + return; + } // Resume pending work. ensureListener(); if ([...subagentRuns.values()].some((entry) => entry.archiveAtMs)) { From d0e008d4601dca29011769f68b83b408953b7baa Mon Sep 17 00:00:00 2001 From: HeMuling <74801533+HeMuling@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:42:31 +0800 Subject: [PATCH 258/314] chore(status): clarify bootstrap file semantics --- .../subagent-registry.persistence.test.ts | 2 +- src/commands/status-all/report-lines.test.ts | 74 +++++++++++++++++++ src/commands/status-all/report-lines.ts | 8 +- src/commands/status.command.ts | 4 +- src/commands/status.test.ts | 1 + 5 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 src/commands/status-all/report-lines.test.ts diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 5558d77785e..1c3db23672f 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -58,7 +58,7 @@ describe("subagent registry persistence", () => { const storePath = resolveSessionStorePath(tempStateDir, agentId); const store = await readSessionStore(storePath); store[params.sessionKey] = { - ...(store[params.sessionKey] ?? {}), + ...store[params.sessionKey], sessionId: params.sessionId ?? `sess-${agentId}-${Date.now()}`, updatedAt: params.updatedAt ?? Date.now(), }; diff --git a/src/commands/status-all/report-lines.test.ts b/src/commands/status-all/report-lines.test.ts new file mode 100644 index 00000000000..5769bc0d41d --- /dev/null +++ b/src/commands/status-all/report-lines.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ProgressReporter } from "../../cli/progress.js"; +import { buildStatusAllReportLines } from "./report-lines.js"; + +const diagnosisSpy = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("./diagnosis.js", () => ({ + appendStatusAllDiagnosis: diagnosisSpy, +})); + +describe("buildStatusAllReportLines", () => { + it("renders bootstrap column using file-presence semantics", async () => { + const progress: ProgressReporter = { + setLabel: () => {}, + setPercent: () => {}, + tick: () => {}, + done: () => {}, + }; + const lines = await buildStatusAllReportLines({ + progress, + overviewRows: [{ Item: "Gateway", Value: "ok" }], + channels: { + rows: [], + details: [], + }, + channelIssues: [], + agentStatus: { + agents: [ + { + id: "main", + bootstrapPending: true, + sessionsCount: 1, + lastActiveAgeMs: 12_000, + sessionsPath: "/tmp/main-sessions.json", + }, + { + id: "ops", + bootstrapPending: false, + sessionsCount: 0, + lastActiveAgeMs: null, + sessionsPath: "/tmp/ops-sessions.json", + }, + ], + }, + connectionDetailsForReport: "", + diagnosis: { + snap: null, + remoteUrlMissing: false, + sentinel: null, + lastErr: null, + port: 18789, + portUsage: null, + tailscaleMode: "off", + tailscale: { + backendState: null, + dnsName: null, + ips: [], + error: null, + }, + tailscaleHttpsUrl: null, + skillStatus: null, + channelsStatus: null, + channelIssues: [], + gatewayReachable: false, + health: null, + }, + }); + + const output = lines.join("\n"); + expect(output).toContain("Bootstrap file"); + expect(output).toContain("PRESENT"); + expect(output).toContain("ABSENT"); + }); +}); diff --git a/src/commands/status-all/report-lines.ts b/src/commands/status-all/report-lines.ts index 71dc035ad84..0db503002bd 100644 --- a/src/commands/status-all/report-lines.ts +++ b/src/commands/status-all/report-lines.ts @@ -121,11 +121,11 @@ export async function buildStatusAllReportLines(params: { const agentRows = params.agentStatus.agents.map((a) => ({ Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id, - Bootstrap: + BootstrapFile: a.bootstrapPending === true - ? warn("PENDING") + ? warn("PRESENT") : a.bootstrapPending === false - ? ok("OK") + ? ok("ABSENT") : "unknown", Sessions: String(a.sessionsCount), Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown", @@ -136,7 +136,7 @@ export async function buildStatusAllReportLines(params: { width: tableWidth, columns: [ { key: "Agent", header: "Agent", minWidth: 12 }, - { key: "Bootstrap", header: "Bootstrap", minWidth: 10 }, + { key: "BootstrapFile", header: "Bootstrap file", minWidth: 14 }, { key: "Sessions", header: "Sessions", align: "right", minWidth: 8 }, { key: "Active", header: "Active", minWidth: 10 }, { key: "Store", header: "Store", flex: true, minWidth: 34 }, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index a613f0896ee..e78faa4cc38 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -265,8 +265,8 @@ export async function statusCommand( const agentsValue = (() => { const pending = agentStatus.bootstrapPendingCount > 0 - ? `${agentStatus.bootstrapPendingCount} bootstrapping` - : "no bootstraps"; + ? `${agentStatus.bootstrapPendingCount} bootstrap file${agentStatus.bootstrapPendingCount === 1 ? "" : "s"} present` + : "no bootstrap files"; const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId); const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown"; const defSuffix = def ? ` · default ${def.id} active ${defActive}` : ""; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 4532acb3ea2..e628d79aa7d 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -388,6 +388,7 @@ describe("statusCommand", () => { expect(logs.some((l: string) => l.includes("Memory"))).toBe(true); expect(logs.some((l: string) => l.includes("Channels"))).toBe(true); expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true); + expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true); expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true); expect(logs.some((l: string) => l.includes("+1000"))).toBe(true); expect(logs.some((l: string) => l.includes("50%"))).toBe(true); From 3c13f4c2b4ec02200f2f780c240e22e0c0277ed0 Mon Sep 17 00:00:00 2001 From: HeMuling <74801533+HeMuling@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:26:00 +0800 Subject: [PATCH 259/314] test(subagents): mock sessions store in steer-restart coverage --- .../subagent-registry.steer-restart.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 0eed4e05532..6a7e86100c6 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -38,6 +38,31 @@ vi.mock("../config/config.js", () => ({ })), })); +vi.mock("../config/sessions.js", () => { + const sessionStore = new Proxy>( + {}, + { + get(target, prop, receiver) { + if (typeof prop !== "string" || prop in target) { + return Reflect.get(target, prop, receiver); + } + return { sessionId: `sess-${prop}`, updatedAt: 1 }; + }, + }, + ); + + return { + loadSessionStore: vi.fn(() => sessionStore), + resolveAgentIdFromSessionKey: (key: string) => { + const match = key.match(/^agent:([^:]+)/); + return match?.[1] ?? "main"; + }, + resolveMainSessionKey: () => "agent:main:main", + resolveStorePath: () => "/tmp/test-store", + updateSessionStore: vi.fn(), + }; +}); + const announceSpy = vi.fn(async (_params: unknown) => true); const runSubagentEndedHookMock = vi.fn(async (_event?: unknown, _ctx?: unknown) => {}); vi.mock("./subagent-announce.js", () => ({ From ffc22778f380c2181a4c377ae0ba636ac07c6284 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:17:42 +0000 Subject: [PATCH 260/314] fix(subagents): prune orphaned restored runs + status wording (#24244) (thanks @HeMuling) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 008661d60ee..07b72149af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. +- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. From 3f5e7f815668e8764ce3f2e46eaa51f370045c84 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Mon, 23 Feb 2026 14:43:38 -0700 Subject: [PATCH 261/314] fix(gateway): consume allow-once approvals to prevent replay (cherry picked from commit 6adacd447c61b7b743d49e8fabab37fb0b2694c5) --- src/gateway/exec-approval-manager.ts | 15 +++++ .../node-invoke-system-run-approval.test.ts | 61 ++++++++++++++++++- .../node-invoke-system-run-approval.ts | 18 +++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/gateway/exec-approval-manager.ts b/src/gateway/exec-approval-manager.ts index a065be1916a..5e582d42a03 100644 --- a/src/gateway/exec-approval-manager.ts +++ b/src/gateway/exec-approval-manager.ts @@ -154,6 +154,21 @@ export class ExecApprovalManager { return entry?.record ?? null; } + consumeAllowOnce(recordId: string): boolean { + const entry = this.pending.get(recordId); + if (!entry) { + return false; + } + const record = entry.record; + if (record.decision !== "allow-once") { + return false; + } + // One-time approvals must be consumed atomically so the same runId + // cannot be replayed during the resolved-entry grace window. + record.decision = undefined; + return true; + } + /** * Wait for decision on an already-registered approval. * Returns the decision promise if the ID is pending, null otherwise. diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index ddae856048b..653f0d47852 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import type { ExecApprovalRecord } from "./exec-approval-manager.js"; +import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js"; import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js"; describe("sanitizeSystemRunParamsForForwarding", () => { @@ -36,8 +36,17 @@ describe("sanitizeSystemRunParamsForForwarding", () => { } function manager(record: ReturnType) { + let consumed = false; return { getSnapshot: () => record, + consumeAllowOnce: () => { + if (consumed || record.decision !== "allow-once") { + return false; + } + consumed = true; + record.decision = undefined; + return true; + }, }; } @@ -130,6 +139,56 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }); expectAllowOnceForwardingResult(result); }); + test("consumes allow-once approvals and blocks same runId replay", async () => { + const approvalManager = new ExecApprovalManager(); + const runId = "approval-replay-1"; + const record = approvalManager.create( + { + host: "node", + command: "echo SAFE", + cwd: null, + agentId: null, + sessionKey: null, + }, + 60_000, + runId, + ); + record.requestedByConnId = "conn-1"; + record.requestedByDeviceId = "dev-1"; + record.requestedByClientId = "cli-1"; + + const decisionPromise = approvalManager.register(record, 60_000); + approvalManager.resolve(runId, "allow-once", "operator"); + await expect(decisionPromise).resolves.toBe("allow-once"); + + const params = { + command: ["echo", "SAFE"], + rawCommand: "echo SAFE", + runId, + approved: true, + approvalDecision: "allow-once", + }; + + const first = sanitizeSystemRunParamsForForwarding({ + rawParams: params, + client, + execApprovalManager: approvalManager, + nowMs: now, + }); + expectAllowOnceForwardingResult(first); + + const second = sanitizeSystemRunParamsForForwarding({ + rawParams: params, + client, + execApprovalManager: approvalManager, + nowMs: now, + }); + expect(second.ok).toBe(false); + if (second.ok) { + throw new Error("unreachable"); + } + expect(second.details?.code).toBe("APPROVAL_REQUIRED"); + }); test("rejects approval ids that do not bind a nodeId", () => { const record = makeRecord("echo SAFE"); diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index 5bf31db8fb5..d5600adf032 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -17,6 +17,7 @@ type SystemRunParamsLike = { type ApprovalLookup = { getSnapshot: (recordId: string) => ExecApprovalRecord | null; + consumeAllowOnce?: (recordId: string) => boolean; }; type ApprovalClient = { @@ -245,9 +246,22 @@ export function sanitizeSystemRunParamsForForwarding(opts: { } // Normal path: enforce the decision recorded by the gateway. - if (snapshot.decision === "allow-once" || snapshot.decision === "allow-always") { + if (snapshot.decision === "allow-once") { + if (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) { + return { + ok: false, + message: "approval required", + details: { code: "APPROVAL_REQUIRED", runId }, + }; + } next.approved = true; - next.approvalDecision = snapshot.decision; + next.approvalDecision = "allow-once"; + return { ok: true, params: next }; + } + + if (snapshot.decision === "allow-always") { + next.approved = true; + next.approvalDecision = "allow-always"; return { ok: true, params: next }; } From c6bb7b0c04f29fb2d0d4687dcef9d4943ac87f51 Mon Sep 17 00:00:00 2001 From: damaozi <1811866786@qq.com> Date: Tue, 24 Feb 2026 03:20:37 +0800 Subject: [PATCH 262/314] fix(whatsapp): groupAllowFrom sender filter bypassed when groupPolicy is allowlist (#24670) (cherry picked from commit af06ebd9a63c5fb91d7481a61fcdd60dac955b59) --- CHANGELOG.md | 1 + src/config/group-policy.test.ts | 40 +++++++++++++++++++ src/config/group-policy.ts | 10 ++++- .../auto-reply/monitor/group-activation.ts | 7 ++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b72149af7..d882c0c83cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. - Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. - Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. - Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. diff --git a/src/config/group-policy.test.ts b/src/config/group-policy.test.ts index 8151f36363b..a3ca8ad5327 100644 --- a/src/config/group-policy.test.ts +++ b/src/config/group-policy.test.ts @@ -89,6 +89,46 @@ describe("resolveChannelGroupPolicy", () => { expect(policy.allowlistEnabled).toBe(true); expect(policy.allowed).toBe(false); }); + + it("allows groups when groupPolicy=allowlist with hasGroupAllowFrom but no groups", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + hasGroupAllowFrom: true, + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(true); + }); + + it("still fails closed when groupPolicy=allowlist without groups or groupAllowFrom", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + hasGroupAllowFrom: false, + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(false); + }); }); describe("resolveToolsBySender", () => { diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index fe8b1542a12..fdb028f9f7c 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -328,6 +328,8 @@ export function resolveChannelGroupPolicy(params: { groupId?: string | null; accountId?: string | null; groupIdCaseInsensitive?: boolean; + /** When true, sender-level filtering (groupAllowFrom) is configured upstream. */ + hasGroupAllowFrom?: boolean; }): ChannelGroupPolicy { const { cfg, channel } = params; const groups = resolveChannelGroups(cfg, channel, params.accountId); @@ -340,8 +342,14 @@ export function resolveChannelGroupPolicy(params: { : undefined; const defaultConfig = groups?.["*"]; const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); + // When groupPolicy is "allowlist" with groupAllowFrom but no explicit groups, + // allow the group through — sender-level filtering handles access control. + const senderFilterBypass = + groupPolicy === "allowlist" && !hasGroups && Boolean(params.hasGroupAllowFrom); const allowed = - groupPolicy === "disabled" ? false : !allowlistEnabled || allowAll || Boolean(groupConfig); + groupPolicy === "disabled" + ? false + : !allowlistEnabled || allowAll || Boolean(groupConfig) || senderFilterBypass; return { allowlistEnabled, allowed, diff --git a/src/web/auto-reply/monitor/group-activation.ts b/src/web/auto-reply/monitor/group-activation.ts index aeb16428fbe..01f96e94528 100644 --- a/src/web/auto-reply/monitor/group-activation.ts +++ b/src/web/auto-reply/monitor/group-activation.ts @@ -16,10 +16,17 @@ export function resolveGroupPolicyFor(cfg: ReturnType, conver ChatType: "group", Provider: "whatsapp", })?.id; + const whatsappCfg = cfg.channels?.whatsapp as + | { groupAllowFrom?: string[]; allowFrom?: string[] } + | undefined; + const hasGroupAllowFrom = Boolean( + whatsappCfg?.groupAllowFrom?.length || whatsappCfg?.allowFrom?.length, + ); return resolveChannelGroupPolicy({ cfg, channel: "whatsapp", groupId: groupId ?? conversationId, + hasGroupAllowFrom, }); } From f3459d71e82838274fbb2aeceac1822e1b3b46bc Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 24 Feb 2026 11:58:52 +0800 Subject: [PATCH 263/314] fix(exec): treat shell exit codes 126/127 as failures instead of completed When a command exits with code 127 (command not found) or 126 (not executable), the exec tool previously returned status "completed" with the error buried in the output text. This caused cron jobs to report status "ok" and never increment consecutiveErrors, silently swallowing failures like `python: command not found` across multiple daily cycles. Now these shell-reserved exit codes are classified as "failed", which propagates through the cron pipeline to properly increment consecutiveErrors and surface the issue for operator attention. Fixes #24587 Co-authored-by: Cursor (cherry picked from commit 2b1d1985ef09000977131bbb1a5c2d732b6cd6e4) --- src/agents/bash-tools.exec-runtime.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 39e36b5581e..2a6db05669c 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -482,7 +482,13 @@ export async function runExecProcess(opts: { .then((exit): ExecProcessOutcome => { const durationMs = Date.now() - startedAt; const isNormalExit = exit.reason === "exit"; - const status: "completed" | "failed" = isNormalExit ? "completed" : "failed"; + const exitCode = exit.exitCode ?? 0; + // Shell exit codes 126 (not executable) and 127 (command not found) are + // unrecoverable infrastructure failures that should surface as real errors + // rather than silently completing — e.g. `python: command not found`. + const isShellFailure = exitCode === 126 || exitCode === 127; + const status: "completed" | "failed" = + isNormalExit && !isShellFailure ? "completed" : "failed"; markExited(session, exit.exitCode, exit.exitSignal, status); maybeNotifyOnExit(session, status); @@ -491,7 +497,6 @@ export async function runExecProcess(opts: { } const aggregated = session.aggregated.trim(); if (status === "completed") { - const exitCode = exit.exitCode ?? 0; const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : ""; return { status: "completed", @@ -502,8 +507,11 @@ export async function runExecProcess(opts: { timedOut: false, }; } - const reason = - exit.reason === "overall-timeout" + const reason = isShellFailure + ? exitCode === 127 + ? "Command not found" + : "Command not executable (permission denied)" + : exit.reason === "overall-timeout" ? typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 ? `Command timed out after ${opts.timeoutSec} seconds` : "Command timed out" From c69fc383b909758acf787288b075a641b4712516 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 04:24:55 +0100 Subject: [PATCH 264/314] fix(config): surface helpful chown hint on EACCES when reading config When the gateway is deployed in a Docker/container environment using a 1-click hosting template, the openclaw.json config file can end up owned by root (mode 600) while the gateway process runs as the non-root 'node' user. This causes a silent EACCES failure: the gateway starts with an empty config and Telegram/Discord bots stop responding. Before this fix the error was logged as a generic 'read failed: ...' message with no indication of how to recover. After this fix: - EACCES errors log a clear, actionable error to stderr (visible in docker logs) with the exact chown command to run - The config snapshot issue message also includes the chown hint so 'openclaw gateway status' / Control UI surface the fix path - process.getuid() is used to include the current UID in the hint; falls back to '1001' on platforms where it is unavailable Fixes #24853 (cherry picked from commit 0a3c572c4175953b0d1284993642b1689678fce4) --- src/config/io.eacces.test.ts | 60 ++++++++++++++++++++++++++++++++++++ src/config/io.ts | 21 ++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/config/io.eacces.test.ts diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts new file mode 100644 index 00000000000..f22b9d8905d --- /dev/null +++ b/src/config/io.eacces.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createConfigIO } from "./io.js"; + +function makeEaccesFs(configPath: string) { + const eaccesErr = Object.assign(new Error(`EACCES: permission denied, open '${configPath}'`), { + code: "EACCES", + }); + return { + existsSync: (p: string) => p === configPath, + readFileSync: (p: string): string => { + if (p === configPath) { + throw eaccesErr; + } + throw new Error(`unexpected readFileSync: ${p}`); + }, + promises: { + readFile: () => Promise.reject(eaccesErr), + mkdir: () => Promise.resolve(), + writeFile: () => Promise.resolve(), + appendFile: () => Promise.resolve(), + }, + } as unknown as typeof import("node:fs").default; +} + +describe("config io EACCES handling", () => { + it("returns a helpful error message when config file is not readable (EACCES)", async () => { + const configPath = "/data/.openclaw/openclaw.json"; + const errors: string[] = []; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { + error: (msg: unknown) => errors.push(String(msg)), + warn: () => {}, + }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(false); + expect(snapshot.issues).toHaveLength(1); + expect(snapshot.issues[0].message).toContain("EACCES"); + expect(snapshot.issues[0].message).toContain("chown"); + expect(snapshot.issues[0].message).toContain(configPath); + // Should also emit to the logger + expect(errors.some((e) => e.includes("chown"))).toBe(true); + }); + + it("includes configPath in the chown hint for the correct remediation command", async () => { + const configPath = "/home/myuser/.openclaw/openclaw.json"; + const io = createConfigIO({ + configPath, + fs: makeEaccesFs(configPath), + logger: { error: () => {}, warn: () => {} }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.issues[0].message).toContain(configPath); + expect(snapshot.issues[0].message).toContain("container"); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index bff292048fb..8dbcf10936c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -936,6 +936,25 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { envSnapshotForRestore: readResolution.envSnapshotForRestore, }; } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + let message: string; + if (nodeErr?.code === "EACCES") { + // Permission denied — common in Docker/container deployments where the + // config file is owned by root but the gateway runs as a non-root user. + const uid = process.getuid?.(); + const uidHint = typeof uid === "number" ? String(uid) : "$(id -u)"; + message = [ + `read failed: ${String(err)}`, + ``, + `Config file is not readable by the current process. If running in a container`, + `or 1-click deployment, fix ownership with:`, + ` chown ${uidHint} "${configPath}"`, + `Then restart the gateway.`, + ].join("\n"); + deps.logger.error(message); + } else { + message = `read failed: ${String(err)}`; + } return { snapshot: { path: configPath, @@ -946,7 +965,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { valid: false, config: {}, hash: hashConfigRaw(null), - issues: [{ path: "", message: `read failed: ${String(err)}` }], + issues: [{ path: "", message }], warnings: [], legacyIssues: [], }, From 2398b5137803a5b68e6d0d03723ad3b827f99180 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 09:59:44 +0800 Subject: [PATCH 265/314] fix: include available_skills in isolated cron agentTurn sessions (closes #24888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildSkillsSection() had an early-return guard on isMinimal that silently dropped the entire block for any session using promptMode="minimal" — which includes all isolated cron agentTurn sessions (isCronSessionKey → promptMode="minimal" in attempt.ts:497-500). Fix: remove the isMinimal guard from buildSkillsSection so that skills are emitted whenever a non-empty skillsPrompt is provided, regardless of mode. Memory, docs, reply-tags, and other verbose sections remain gated on isMinimal. Tests added: - "includes skills in minimal prompt mode when skillsPrompt is provided (cron regression)" - "omits skills in minimal prompt mode when skillsPrompt is absent" - Updated existing minimal-mode test expectation to match corrected behaviour. (cherry picked from commit 66af86e7eede75721a0439cff595209aa4548eff) --- src/agents/system-prompt.test.ts | 26 +++++++++++++++++++++++++- src/agents/system-prompt.ts | 3 --- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index fa6d4de6563..b45c64e72ec 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -108,7 +108,8 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).not.toContain("## Authorized Senders"); - expect(prompt).not.toContain("## Skills"); + // Skills are included even in minimal mode when skillsPrompt is provided (cron sessions need them) + expect(prompt).toContain("## Skills"); expect(prompt).not.toContain("## Memory Recall"); expect(prompt).not.toContain("## Documentation"); expect(prompt).not.toContain("## Reply Tags"); @@ -131,6 +132,29 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("Subagent details"); }); + it("includes skills in minimal prompt mode when skillsPrompt is provided (cron regression)", () => { + // Isolated cron sessions use promptMode="minimal" but must still receive skills. + const skillsPrompt = + "\n \n demo\n \n"; + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + skillsPrompt, + }); + + expect(prompt).toContain("## Skills (mandatory)"); + expect(prompt).toContain(""); + }); + + it("omits skills in minimal prompt mode when skillsPrompt is absent", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + }); + + expect(prompt).not.toContain("## Skills"); + }); + it("includes safety guardrails in full prompts", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 9027bba92d7..b0b3ed8be0c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -22,9 +22,6 @@ function buildSkillsSection(params: { isMinimal: boolean; readToolName: string; }) { - if (params.isMinimal) { - return []; - } const trimmed = params.skillsPrompt?.trim(); if (!trimmed) { return []; From c7bf0dacb809a8f2ddf344d8e66753f7c58b00ba Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 10:39:41 +0800 Subject: [PATCH 266/314] chore: remove unused isMinimal param from buildSkillsSection Address review feedback: isMinimal is no longer referenced after the early-return guard was removed in the parent commit. (cherry picked from commit 2efe04d301c386f1c7dc93d4ae60de8fac8a63b2) --- src/agents/system-prompt.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index b0b3ed8be0c..d052daf5f7d 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -17,11 +17,7 @@ import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js"; export type PromptMode = "full" | "minimal" | "none"; type OwnerIdDisplay = "raw" | "hash"; -function buildSkillsSection(params: { - skillsPrompt?: string; - isMinimal: boolean; - readToolName: string; -}) { +function buildSkillsSection(params: { skillsPrompt?: string; readToolName: string }) { const trimmed = params.skillsPrompt?.trim(); if (!trimmed) { return []; @@ -392,7 +388,6 @@ export function buildAgentSystemPrompt(params: { ]; const skillsSection = buildSkillsSection({ skillsPrompt, - isMinimal, readToolName, }); const memorySection = buildMemorySection({ From 01c1f68ab3c333b45c806687ec7d40675bcececb Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Mon, 23 Feb 2026 23:17:36 -0300 Subject: [PATCH 267/314] fix(hooks): decouple message:sent internal hook from mirror param (cherry picked from commit 1afd7030f8e5e9dda682f1de5942a9662ac7dbcf) --- src/infra/outbound/deliver.test.ts | 31 +++++++++++++++++++++++++++++- src/infra/outbound/deliver.ts | 4 +++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 0927de7df99..c39d966b804 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -478,7 +478,7 @@ describe("deliverOutboundPayloads", () => { expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); }); - it("does not emit internal message:sent hook when mirror sessionKey is missing", async () => { + it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => { const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); await deliverOutboundPayloads({ @@ -493,6 +493,35 @@ describe("deliverOutboundPayloads", () => { expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled(); }); + it("emits internal message:sent hook when sessionKey is provided without mirror", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hello" }], + deps: { sendWhatsApp }, + sessionKey: "agent:main:main", + }); + + expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1); + expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith( + "message", + "sent", + "agent:main:main", + expect.objectContaining({ + to: "+1555", + content: "hello", + success: true, + channelId: "whatsapp", + conversationId: "+1555", + messageId: "w1", + }), + ); + expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); + }); + it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => { const sendWhatsApp = vi .fn() diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 908b786e5ee..f071a25d048 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -216,6 +216,8 @@ type DeliverOutboundPayloadsCoreParams = { mediaUrls?: string[]; }; silent?: boolean; + /** Session key for internal hook dispatch (when `mirror` is not needed). */ + sessionKey?: string; }; type DeliverOutboundPayloadsParams = DeliverOutboundPayloadsCoreParams & { @@ -444,7 +446,7 @@ async function deliverOutboundPayloadsCore( return normalized ? [normalized] : []; }); const hookRunner = getGlobalHookRunner(); - const sessionKeyForInternalHooks = params.mirror?.sessionKey; + const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.sessionKey; for (const payload of normalizedPayloads) { const payloadSummary: NormalizedOutboundPayload = { text: payload.text ?? "", From ac6cec7677a6ea1185891107d9a32c51a4269109 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Mon, 23 Feb 2026 21:42:36 +0100 Subject: [PATCH 268/314] fix(providers): strip trailing /v1 from Anthropic baseUrl to prevent double-path The pi-ai Anthropic provider constructs the full API endpoint as `${baseUrl}/v1/messages`. If a user configures `models.providers.anthropic.baseUrl` with a trailing `/v1` (e.g. "https://api.anthropic.com/v1"), the resolved URL becomes "https://api.anthropic.com/v1/v1/messages" which the Anthropic API rejects with a 404 / connection failure. This regression appeared in v2026.2.22 when @mariozechner/pi-ai bumped from 0.54.0 to 0.54.1, which started appending the /v1 segment where the previous version did not. Fix: in normalizeModelCompat(), detect anthropic-messages models and strip a single trailing /v1 (with optional trailing slash) from the configured baseUrl before it is handed to pi-ai. Models with baseUrls that do not end in /v1 are unaffected. Non-anthropic-messages models are not touched. Adds 6 unit tests covering the normalisation scenarios. Fixes #24709 (cherry picked from commit 4c4857fdcb3506dc277f9df75d4df5879dca8d41) --- src/agents/model-compat.test.ts | 59 +++++++++++++++++++++++++++++++++ src/agents/model-compat.ts | 26 +++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index a7404d3042b..1e11b12437f 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -41,6 +41,65 @@ function createRegistry(models: Record>): ModelRegistry { } as ModelRegistry; } +describe("normalizeModelCompat — Anthropic baseUrl", () => { + const anthropicBase = (): Model => + ({ + id: "claude-opus-4-6", + name: "claude-opus-4-6", + api: "anthropic-messages", + provider: "anthropic", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }) as Model; + + it("strips /v1 suffix from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("strips trailing /v1/ (with slash) from anthropic-messages baseUrl", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com/v1/" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves anthropic-messages baseUrl without /v1 unchanged", () => { + const model = { ...anthropicBase(), baseUrl: "https://api.anthropic.com" }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.anthropic.com"); + }); + + it("leaves baseUrl undefined unchanged for anthropic-messages", () => { + const model = anthropicBase(); + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBeUndefined(); + }); + + it("does not strip /v1 from non-anthropic-messages models", () => { + const model = { + ...baseModel(), + provider: "openai", + api: "openai-responses" as Api, + baseUrl: "https://api.openai.com/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://api.openai.com/v1"); + }); + + it("strips /v1 from custom Anthropic proxy baseUrl", () => { + const model = { + ...anthropicBase(), + baseUrl: "https://my-proxy.example.com/anthropic/v1", + }; + const normalized = normalizeModelCompat(model); + expect(normalized.baseUrl).toBe("https://my-proxy.example.com/anthropic"); + }); +}); + describe("normalizeModelCompat", () => { it("forces supportsDeveloperRole off for z.ai models", () => { const model = baseModel(); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 2b5eba1301c..fc1c195819a 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -12,8 +12,34 @@ function isDashScopeCompatibleEndpoint(baseUrl: string): boolean { ); } +function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { + return model.api === "anthropic-messages"; +} + +/** + * pi-ai constructs the Anthropic API endpoint as `${baseUrl}/v1/messages`. + * If a user configures `baseUrl` with a trailing `/v1` (e.g. the previously + * recommended format "https://api.anthropic.com/v1"), the resulting URL + * becomes "…/v1/v1/messages" which the Anthropic API rejects with a 404. + * + * Strip a single trailing `/v1` (with optional trailing slash) from the + * baseUrl for anthropic-messages models so users with either format work. + */ +function normalizeAnthropicBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/v1\/?$/, ""); +} export function normalizeModelCompat(model: Model): Model { const baseUrl = model.baseUrl ?? ""; + + // Normalise anthropic-messages baseUrl: strip trailing /v1 that users may + // have included in their config. pi-ai appends /v1/messages itself. + if (isAnthropicMessagesModel(model) && baseUrl) { + const normalised = normalizeAnthropicBaseUrl(baseUrl); + if (normalised !== baseUrl) { + return { ...model, baseUrl: normalised } as Model<"anthropic-messages">; + } + } + const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai"); const isMoonshot = model.provider === "moonshot" || From dd14daab150ba9bb327003ac76d01a5515e432ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:18:43 +0000 Subject: [PATCH 269/314] fix(telegram): allowlist api.telegram.org in media SSRF policy --- src/telegram/bot/delivery.resolve-media-retry.test.ts | 5 ++++- src/telegram/bot/delivery.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 2becbcd93e9..d6f4e8fadc0 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -95,7 +95,10 @@ async function expectTransientGetFileRetrySuccess() { expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, - ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + ssrfPolicy: { + allowRfc2544BenchmarkRange: true, + allowedHostnames: ["api.telegram.org"], + }, }), ); return result; diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index a20bf045610..019f42ced1d 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -36,6 +36,9 @@ const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; const TELEGRAM_MEDIA_SSRF_POLICY = { + // Telegram file downloads should trust api.telegram.org even when DNS/proxy + // resolution maps to private/internal ranges in restricted networks. + allowedHostnames: ["api.telegram.org"], allowRfc2544BenchmarkRange: true, } as const; From 803e02d8dfd3985d2df9d94608f7148714c3d0ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:20:24 +0000 Subject: [PATCH 270/314] fix: adapt landed fixups to current type and approval constraints --- src/config/io.eacces.test.ts | 2 +- src/gateway/node-invoke-system-run-approval.test.ts | 3 +++ src/telegram/bot/delivery.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/config/io.eacces.test.ts b/src/config/io.eacces.test.ts index f22b9d8905d..ab56e27a659 100644 --- a/src/config/io.eacces.test.ts +++ b/src/config/io.eacces.test.ts @@ -19,7 +19,7 @@ function makeEaccesFs(configPath: string) { writeFile: () => Promise.resolve(), appendFile: () => Promise.resolve(), }, - } as unknown as typeof import("node:fs").default; + } as unknown as typeof import("node:fs"); } describe("config io EACCES handling", () => { diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index 653f0d47852..196b5947f45 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -145,6 +145,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { const record = approvalManager.create( { host: "node", + nodeId: "node-1", command: "echo SAFE", cwd: null, agentId: null, @@ -170,6 +171,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }; const first = sanitizeSystemRunParamsForForwarding({ + nodeId: "node-1", rawParams: params, client, execApprovalManager: approvalManager, @@ -178,6 +180,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => { expectAllowOnceForwardingResult(first); const second = sanitizeSystemRunParamsForForwarding({ + nodeId: "node-1", rawParams: params, client, execApprovalManager: approvalManager, diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 019f42ced1d..5e0cfb2ea1f 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -40,7 +40,7 @@ const TELEGRAM_MEDIA_SSRF_POLICY = { // resolution maps to private/internal ranges in restricted networks. allowedHostnames: ["api.telegram.org"], allowRfc2544BenchmarkRange: true, -} as const; +}; export async function deliverReplies(params: { replies: ReplyPayload[]; From 5710d72527287df593894da2365b53dcaf924fdc Mon Sep 17 00:00:00 2001 From: Mitch McAlister Date: Mon, 23 Feb 2026 17:25:08 +0000 Subject: [PATCH 271/314] feat(agents): configurable default runTimeoutSeconds for subagent spawns When sessions_spawn is called without runTimeoutSeconds, subagents previously defaulted to 0 (no timeout). This adds a config key at agents.defaults.subagents.runTimeoutSeconds so operators can set a global default timeout for all subagent runs. The agent-provided value still takes precedence when explicitly passed. When neither the agent nor the config specifies a timeout, behavior is unchanged (0 = no timeout), preserving backwards compatibility. Updated for the subagent-spawn.ts refactor (logic moved from sessions-spawn-tool.ts to spawnSubagentDirect). Closes #19288 Co-Authored-By: Claude Opus 4.6 --- ...sions-spawn-default-timeout-absent.test.ts | 69 ++++++++++++++++ ...nts.sessions-spawn-default-timeout.test.ts | 79 +++++++++++++++++++ src/agents/subagent-spawn.ts | 14 +++- src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + 5 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts create mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts new file mode 100644 index 00000000000..947c83333fd --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + maxConcurrent: 8, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-456" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds (config absent)", () => { + it("falls back to 0 (no timeout) when config key is absent", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(0); + }); +}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts new file mode 100644 index 00000000000..8186b8bde95 --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + runTimeoutSeconds: 900, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-123" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds", () => { + it("uses config default when agent omits runTimeoutSeconds", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(900); + }); + + it("explicit runTimeoutSeconds wins over config default", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(300); + }); +}); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index d033c78bc3e..7d4f672f2f1 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -193,14 +193,22 @@ export async function spawnSubagentDirect( threadId: ctx.agentThreadId, }); const hookRunner = getGlobalHookRunner(); + const cfg = loadConfig(); + + // When agent omits runTimeoutSeconds, use the config default. + // Falls back to 0 (no timeout) if config key is also unset, + // preserving current behavior for existing deployments. + const cfgSubagentTimeout = + typeof cfg?.agents?.defaults?.subagents?.runTimeoutSeconds === "number" && + Number.isFinite(cfg.agents.defaults.subagents.runTimeoutSeconds) + ? Math.max(0, Math.floor(cfg.agents.defaults.subagents.runTimeoutSeconds)) + : 0; const runTimeoutSeconds = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : 0; + : cfgSubagentTimeout; let modelApplied = false; let threadBindingReady = false; - - const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = ctx.agentSessionKey; const requesterInternalKey = requesterSessionKey diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 7ecfc6d4193..e8eac685086 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -247,6 +247,8 @@ export type AgentDefaultsConfig = { model?: AgentModelConfig; /** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */ thinking?: string; + /** Default run timeout in seconds for spawned sub-agents (0 = no timeout). */ + runTimeoutSeconds?: number; /** Gateway timeout in ms for sub-agent announce delivery calls (default: 60000). */ announceTimeoutMs?: number; }; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a4fb3c2443b..6f80698f079 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -146,6 +146,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), + runTimeoutSeconds: z.number().min(0).optional(), announceTimeoutMs: z.number().int().positive().optional(), }) .strict() From 8bcd405b1cb72f2aec671762fcdc5ef1c290d57e Mon Sep 17 00:00:00 2001 From: Mitch McAlister Date: Mon, 23 Feb 2026 17:33:58 +0000 Subject: [PATCH 272/314] fix: add .int() to runTimeoutSeconds zod schema for consistency Matches convention used by all other *Seconds/*Ms timeout fields. Co-Authored-By: Claude Opus 4.6 --- src/config/zod-schema.agent-defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 6f80698f079..aa39a70978b 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -146,7 +146,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), - runTimeoutSeconds: z.number().min(0).optional(), + runTimeoutSeconds: z.number().int().min(0).optional(), announceTimeoutMs: z.number().int().positive().optional(), }) .strict() From 8c5cf2d5b275203cb25f1db9f3d8c259725c3ed3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:22:25 +0000 Subject: [PATCH 273/314] docs(subagents): document default runTimeoutSeconds config (#24594) (thanks @mitchmcalister) --- CHANGELOG.md | 4 ++++ docs/concepts/session-tool.md | 2 +- docs/gateway/configuration-reference.md | 2 ++ docs/tools/index.md | 1 + docs/tools/subagents.md | 4 +++- 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d882c0c83cb..d1efde75b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Docs: https://docs.openclaw.ai - **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +### Changes + +- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. + ### Fixes - Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index ebac95dbe55..bbd58d599ce 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -152,7 +152,7 @@ Parameters: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values error) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default 0; when set, aborts the sub-agent run after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, aborts the sub-agent run after N seconds) - `thread?` (default false; request thread-bound routing for this spawn when supported by the channel/plugin) - `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`) - `cleanup?` (`delete|keep`, default `keep`) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8ff41036354..0b89a272d90 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1683,6 +1683,7 @@ Notes: subagents: { model: "minimax/MiniMax-M2.1", maxConcurrent: 1, + runTimeoutSeconds: 900, archiveAfterMinutes: 60, }, }, @@ -1691,6 +1692,7 @@ Notes: ``` - `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model. +- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout. - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`. --- diff --git a/docs/tools/index.md b/docs/tools/index.md index 88b2ee6bccd..269b6856d03 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -478,6 +478,7 @@ Notes: - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`). - If `thread: true` and `mode` is omitted, mode defaults to `session`. - `mode: "session"` requires `thread: true`. + - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout). - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`. - Reply format includes `Status`, `Result`, and compact stats. - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 7334da1ec40..9542858c840 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -71,6 +71,7 @@ Use `sessions_spawn`: - Then runs an announce step and posts the announce reply to the requester chat channel - Default model: inherits the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`); an explicit `sessions_spawn.model` still wins. - Default thinking: inherits the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`); an explicit `sessions_spawn.thinking` still wins. +- Default run timeout: if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). Tool params: @@ -79,7 +80,7 @@ Tool params: - `agentId?` (optional; spawn under another agent id if allowed) - `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result) - `thinking?` (optional; overrides thinking level for the sub-agent run) -- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds) +- `runTimeoutSeconds?` (defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`; when set, the sub-agent run is aborted after N seconds) - `thread?` (default `false`; when `true`, requests channel thread binding for this sub-agent session) - `mode?` (`run|session`) - default is `run` @@ -148,6 +149,7 @@ By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). Y maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) maxChildrenPerAgent: 5, // max active children per agent session (default: 5) maxConcurrent: 8, // global concurrency lane cap (default: 8) + runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout) }, }, }, From f9de17106af64940e41642418ebce15aacc6b8b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:23:16 +0000 Subject: [PATCH 274/314] refactor(browser): share relay token + options validation tests --- assets/chrome-extension/background.js | 2 +- assets/chrome-extension/options-validation.js | 57 +++++++++ assets/chrome-extension/options.js | 58 ++------- ...hrome-extension-options-validation.test.ts | 113 ++++++++++++++++++ 4 files changed, 179 insertions(+), 51 deletions(-) create mode 100644 assets/chrome-extension/options-validation.js create mode 100644 src/browser/chrome-extension-options-validation.test.ts diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 94956571101..60f50d6551e 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,4 +1,4 @@ -import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' const DEFAULT_PORT = 18792 diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 00000000000..53e2cd55014 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 96b87768dae..aa6fcc4901f 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -13,17 +16,6 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } -async function deriveRelayToken(gatewayToken, port) { - const enc = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', enc.encode(gatewayToken), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], - ) - const sig = await crypto.subtle.sign( - 'HMAC', key, enc.encode(`openclaw-extension-relay-v1:${port}`), - ) - return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('') -} - function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -47,46 +39,12 @@ async function checkRelayReachable(port, token) { url, token: relayToken, }) - if (!res) throw new Error('No response from service worker') - if (res.status === 401) { - setStatus('error', 'Gateway token rejected. Check token and save again.') - return - } - if (res.error) throw new Error(res.error) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - - // Validate that this is a CDP relay /json/version payload, not gateway HTML. - const contentType = String(res.contentType || '') - const data = res.json - if (!contentType.includes('application/json')) { - setStatus( - 'error', - 'Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - return - } - if (!data || typeof data !== 'object' || !('Browser' in data) || !('Protocol-Version' in data)) { - setStatus( - 'error', - 'Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - return - } - - setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) } catch (err) { - const message = String(err || '').toLowerCase() - if (message.includes('json') || message.includes('syntax')) { - setStatus( - 'error', - 'Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).', - ) - } else { - setStatus( - 'error', - `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, - ) - } + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } diff --git a/src/browser/chrome-extension-options-validation.test.ts b/src/browser/chrome-extension-options-validation.test.ts new file mode 100644 index 00000000000..23aa6d1ce06 --- /dev/null +++ b/src/browser/chrome-extension-options-validation.test.ts @@ -0,0 +1,113 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +type RelayCheckResponse = { + status?: number; + ok?: boolean; + error?: string; + contentType?: string; + json?: unknown; +}; + +type RelayCheckStatus = + | { action: "throw"; error: string } + | { action: "status"; kind: "ok" | "error"; message: string }; + +type RelayCheckExceptionStatus = { kind: "error"; message: string }; + +type OptionsValidationModule = { + classifyRelayCheckResponse: ( + res: RelayCheckResponse | null | undefined, + port: number, + ) => RelayCheckStatus; + classifyRelayCheckException: (err: unknown, port: number) => RelayCheckExceptionStatus; +}; + +const require = createRequire(import.meta.url); +const OPTIONS_VALIDATION_MODULE = "../../assets/chrome-extension/options-validation.js"; + +async function loadOptionsValidation(): Promise { + try { + return require(OPTIONS_VALIDATION_MODULE) as OptionsValidationModule; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Unexpected token 'export'")) { + throw error; + } + return (await import(OPTIONS_VALIDATION_MODULE)) as OptionsValidationModule; + } +} + +const { classifyRelayCheckException, classifyRelayCheckResponse } = await loadOptionsValidation(); + +describe("chrome extension options validation", () => { + it("maps 401 response to token rejected error", () => { + const result = classifyRelayCheckResponse({ status: 401, ok: false }, 18792); + expect(result).toEqual({ + action: "status", + kind: "error", + message: "Gateway token rejected. Check token and save again.", + }); + }); + + it("maps non-json 200 response to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "text/html; charset=utf-8", json: null }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: this is likely the gateway, not the relay. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps json response without CDP keys to wrong-port error", () => { + const result = classifyRelayCheckResponse( + { status: 200, ok: true, contentType: "application/json", json: { ok: true } }, + 18792, + ); + expect(result).toEqual({ + action: "status", + kind: "error", + message: + "Wrong port: expected relay /json/version response. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps valid relay json response to success", () => { + const result = classifyRelayCheckResponse( + { + status: 200, + ok: true, + contentType: "application/json", + json: { Browser: "Chrome/136", "Protocol-Version": "1.3" }, + }, + 19004, + ); + expect(result).toEqual({ + action: "status", + kind: "ok", + message: "Relay reachable and authenticated at http://127.0.0.1:19004/", + }); + }); + + it("maps syntax/json exceptions to wrong-endpoint error", () => { + const result = classifyRelayCheckException(new Error("SyntaxError: Unexpected token <"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Wrong port: this is not a relay endpoint. Use gateway port + 3 (for gateway 18789, relay is 18792).", + }); + }); + + it("maps generic exceptions to relay unreachable error", () => { + const result = classifyRelayCheckException(new Error("TypeError: Failed to fetch"), 18792); + expect(result).toEqual({ + kind: "error", + message: + "Relay not reachable/authenticated at http://127.0.0.1:18792/. Start OpenClaw browser relay and verify token.", + }); + }); +}); From d51a4695f0cefbed1df0795fb82c27223282caab Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Mon, 23 Feb 2026 21:18:10 -0700 Subject: [PATCH 275/314] Deny cron tool on /tools/invoke by default (cherry picked from commit 816a6b3a4df5bf8436f08e3fc8fa82411e3543ac) --- .../tools-invoke-http.cron-regression.test.ts | 123 ++++++++++++++++++ src/security/dangerous-tools.ts | 2 + 2 files changed, 125 insertions(+) create mode 100644 src/gateway/tools-invoke-http.cron-regression.test.ts diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts new file mode 100644 index 00000000000..a3df263387b --- /dev/null +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -0,0 +1,123 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; + +let cfg: Record = {}; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => cfg, +})); + +vi.mock("../config/sessions.js", () => ({ + resolveMainSessionKey: () => "agent:main:main", +})); + +vi.mock("./auth.js", () => ({ + authorizeHttpGatewayConnect: async () => ({ ok: true }), +})); + +vi.mock("../logger.js", () => ({ + logWarn: () => {}, +})); + +vi.mock("../plugins/config-state.js", () => ({ + isTestDefaultMemorySlotDisabled: () => false, +})); + +vi.mock("../plugins/tools.js", () => ({ + getPluginToolMeta: () => undefined, +})); + +vi.mock("../agents/openclaw-tools.js", () => { + const tools = [ + { + name: "cron", + parameters: { type: "object", properties: { action: { type: "string" } } }, + execute: async () => ({ ok: true, via: "cron" }), + }, + { + name: "gateway", + parameters: { type: "object", properties: { action: { type: "string" } } }, + execute: async () => ({ ok: true, via: "gateway" }), + }, + ]; + return { + createOpenClawTools: () => tools, + }; +}); + +const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js"); + +let port = 0; +let server: ReturnType | undefined; + +beforeAll(async () => { + server = createServer((req, res) => { + void handleToolsInvokeHttpRequest(req, res, { + auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false }, + }).then((handled) => { + if (handled) { + return; + } + res.statusCode = 404; + res.end("not found"); + }); + }); + await new Promise((resolve, reject) => { + server?.once("error", reject); + server?.listen(0, "127.0.0.1", () => { + const address = server?.address() as AddressInfo | null; + port = address?.port ?? 0; + resolve(); + }); + }); +}); + +afterAll(async () => { + if (!server) { + return; + } + await new Promise((resolve) => server?.close(() => resolve())); + server = undefined; +}); + +beforeEach(() => { + cfg = {}; +}); + +async function invoke(tool: string) { + return await fetch(`http://127.0.0.1:${port}/tools/invoke`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${TEST_GATEWAY_TOKEN}`, + }, + body: JSON.stringify({ tool, action: "status", args: {}, sessionKey: "main" }), + }); +} + +describe("tools invoke HTTP denylist", () => { + it("blocks cron and gateway by default", async () => { + const gatewayRes = await invoke("gateway"); + const cronRes = await invoke("cron"); + + expect(gatewayRes.status).toBe(404); + expect(cronRes.status).toBe(404); + }); + + it("allows cron only when explicitly enabled in gateway.tools.allow", async () => { + cfg = { + gateway: { + tools: { + allow: ["cron"], + }, + }, + }; + + const cronRes = await invoke("cron"); + + expect(cronRes.status).toBe(200); + }); +}); diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index be585913bde..6d1274723a5 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -11,6 +11,8 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ "sessions_spawn", // Cross-session injection — message injection across sessions "sessions_send", + // Persistent automation control plane — can create/update/remove scheduled runs + "cron", // Gateway control plane — prevents gateway reconfiguration via HTTP "gateway", // Interactive setup — requires terminal QR scan, hangs on HTTP From 24e52f53e487ec1ea434a8352bc0ffe45b51b5d7 Mon Sep 17 00:00:00 2001 From: HCL Date: Tue, 24 Feb 2026 12:04:06 +0800 Subject: [PATCH 276/314] fix(cli): resolve --url option collision in browser cookies set When addGatewayClientOptions registers --url on the parent browser command, Commander.js captures it before the cookies set subcommand can receive it. Switch from requiredOption to option and resolve via inheritOptionFromParent, matching the existing pattern used for --target-id. Fixes #24811 Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 96fcb963ec6ef4254898aa2afa91d85b61ce677a) --- src/cli/browser-cli-state.cookies-storage.ts | 21 ++++++++++++-- ...rowser-cli-state.option-collisions.test.ts | 29 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/cli/browser-cli-state.cookies-storage.ts b/src/cli/browser-cli-state.cookies-storage.ts index d71cb9a0434..c3b03404f3a 100644 --- a/src/cli/browser-cli-state.cookies-storage.ts +++ b/src/cli/browser-cli-state.cookies-storage.ts @@ -4,6 +4,17 @@ import { defaultRuntime } from "../runtime.js"; import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js"; import { inheritOptionFromParent } from "./command-options.js"; +function resolveUrl(opts: { url?: string }, command: Command): string | undefined { + if (typeof opts.url === "string" && opts.url.trim()) { + return opts.url.trim(); + } + const inherited = inheritOptionFromParent(command, "url"); + if (typeof inherited === "string" && inherited.trim()) { + return inherited.trim(); + } + return undefined; +} + function resolveTargetId(rawTargetId: unknown, command: Command): string | undefined { const local = typeof rawTargetId === "string" ? rawTargetId.trim() : ""; if (local) { @@ -58,12 +69,18 @@ export function registerBrowserCookiesAndStorageCommands( .description("Set a cookie (requires --url or domain+path)") .argument("", "Cookie name") .argument("", "Cookie value") - .requiredOption("--url ", "Cookie URL scope (recommended)") + .option("--url ", "Cookie URL scope (recommended)") .option("--target-id ", "CDP target id (or unique prefix)") .action(async (name: string, value: string, opts, cmd) => { const parent = parentOpts(cmd); const profile = parent?.browserProfile; const targetId = resolveTargetId(opts.targetId, cmd); + const url = resolveUrl(opts, cmd); + if (!url) { + defaultRuntime.error(danger("Missing required --url option for cookies set")); + defaultRuntime.exit(1); + return; + } try { const result = await callBrowserRequest( parent, @@ -73,7 +90,7 @@ export function registerBrowserCookiesAndStorageCommands( query: profile ? { profile } : undefined, body: { targetId, - cookie: { name, value, url: opts.url }, + cookie: { name, value, url }, }, }, { timeoutMs: 20000 }, diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 7284a2de048..45ec5c6a5c1 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -26,12 +26,15 @@ vi.mock("../runtime.js", () => ({ })); describe("browser state option collisions", () => { - const createBrowserProgram = () => { + const createBrowserProgram = ({ withGatewayUrl = false } = {}) => { const program = new Command(); const browser = program .command("browser") .option("--browser-profile ", "Browser profile") .option("--json", "Output JSON", false); + if (withGatewayUrl) { + browser.option("--url ", "Gateway WebSocket URL"); + } const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; registerBrowserStateCommands(browser, parentOpts); return program; @@ -79,6 +82,30 @@ describe("browser state option collisions", () => { expect((request as { body?: { targetId?: string } }).body?.targetId).toBe("tab-1"); }); + it("resolves --url via parent when addGatewayClientOptions captures it", async () => { + const program = createBrowserProgram({ withGatewayUrl: true }); + await program.parseAsync( + ["browser", "--url", "ws://gw", "cookies", "set", "session", "abc", "--url", "https://example.com"], + { from: "user" }, + ); + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + const request = call![1] as { body?: { cookie?: { url?: string } } }; + expect(request.body?.cookie?.url).toBe("https://example.com"); + }); + + it("inherits --url from parent when subcommand does not provide it", async () => { + const program = createBrowserProgram({ withGatewayUrl: true }); + await program.parseAsync( + ["browser", "--url", "https://inherited.example.com", "cookies", "set", "session", "abc"], + { from: "user" }, + ); + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + const request = call![1] as { body?: { cookie?: { url?: string } } }; + expect(request.body?.cookie?.url).toBe("https://inherited.example.com"); + }); + it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => { const request = (await runBrowserCommandAndGetRequest([ "set", From fd7ca4c3945f3cb4cdc94b27ad6a7566e9492215 Mon Sep 17 00:00:00 2001 From: Glucksberg Date: Mon, 23 Feb 2026 02:14:07 +0000 Subject: [PATCH 277/314] fix: normalize input peer.kind in resolveAgentRoute (#22730) The input peer.kind from channel plugins was used as-is without normalization via normalizeChatType(), while the binding side correctly normalized. This caused "dm" !== "direct" mismatches in matchesBindingScope, making plugins that use "dm" as peerKind fail to match bindings configured with "direct". Normalize both peer.kind and parentPeer.kind through normalizeChatType() so that "dm" and "direct" are treated equivalently on both sides. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit b0c96702f5531287f857410303b2c3cc698a1441) --- src/routing/resolve-route.test.ts | 24 ++++++++++++++++++++++++ src/routing/resolve-route.ts | 12 ++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 5337731f3e2..c92bfe2ba17 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -521,6 +521,30 @@ describe("backward compatibility: peer.kind dm → direct", () => { expect(route.agentId).toBe("alex"); expect(route.matchedBy).toBe("binding.peer"); }); + + test("runtime dm peer.kind matches config direct binding (#22730)", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "alex", + match: { + channel: "whatsapp", + // Config uses canonical "direct" + peer: { kind: "direct", id: "+15551234567" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + // Plugin sends "dm" instead of "direct" + peer: { kind: "dm" as ChatType, id: "+15551234567" }, + }); + expect(route.agentId).toBe("alex"); + expect(route.matchedBy).toBe("binding.peer"); + }); }); describe("role-based agent routing", () => { diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 6dab84d3420..74f1b3831b4 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -291,7 +291,12 @@ function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope) export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute { const channel = normalizeToken(input.channel); const accountId = normalizeAccountId(input.accountId); - const peer = input.peer ? { kind: input.peer.kind, id: normalizeId(input.peer.id) } : null; + const peer = input.peer + ? { + kind: normalizeChatType(input.peer.kind) ?? input.peer.kind, + id: normalizeId(input.peer.id), + } + : null; const guildId = normalizeId(input.guildId); const teamId = normalizeId(input.teamId); const memberRoleIds = input.memberRoleIds ?? []; @@ -351,7 +356,10 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } // Thread parent inheritance: if peer (thread) didn't match, check parent peer binding const parentPeer = input.parentPeer - ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } + ? { + kind: normalizeChatType(input.parentPeer.kind) ?? input.parentPeer.kind, + id: normalizeId(input.parentPeer.id), + } : null; const baseScope = { guildId, From 3823587ada2efd7d63f216fa6045f39011986715 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 13:35:14 -0500 Subject: [PATCH 278/314] fix(agents): allow empty edit replacement text (cherry picked from commit 3c21fc30d38ae69f59c7200cfae76642473b2f03) --- src/agents/pi-tools.read.ts | 1 + src/agents/pi-tools.workspace-paths.test.ts | 25 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 93abd66f2d5..a5fb9a1ccd0 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -353,6 +353,7 @@ export const CLAUDE_PARAM_GROUPS = { { keys: ["newText", "new_string"], label: "newText (newText or new_string)", + allowEmpty: true, }, ], } as const; diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 625c04227d3..969bc448caf 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -60,6 +60,31 @@ describe("workspace path resolution", () => { }); }); + it("allows deletion edits with empty newText", async () => { + await withTempDir("openclaw-ws-", async (workspaceDir) => { + await withTempDir("openclaw-cwd-", async (otherDir) => { + const testFile = "delete.txt"; + await fs.writeFile(path.join(workspaceDir, testFile), "hello world", "utf8"); + + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(otherDir); + try { + const tools = createOpenClawCodingTools({ workspaceDir }); + const { editTool } = expectReadWriteEditTools(tools); + + await editTool.execute("ws-edit-delete", { + path: testFile, + oldText: " world", + newText: "", + }); + + expect(await fs.readFile(path.join(workspaceDir, testFile), "utf8")).toBe("hello"); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + }); + it("defaults exec cwd to workspaceDir when workdir is omitted", async () => { await withTempDir("openclaw-ws-", async (workspaceDir) => { const tools = createOpenClawCodingTools({ From 792bd6195c2ac92a2ff846cd1b68963faf47e4a0 Mon Sep 17 00:00:00 2001 From: JackyWay Date: Tue, 24 Feb 2026 00:49:03 +0800 Subject: [PATCH 279/314] fix: recognize Bedrock as Anthropic-compatible in transcript policy (cherry picked from commit 3b5154081cdd6f9ff94b35c50b8f57714f9ad381) --- src/agents/transcript-policy.test.ts | 13 +++++++++++++ src/agents/transcript-policy.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 4ef038c81b7..5f7d151ee9a 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -53,6 +53,19 @@ describe("resolveTranscriptPolicy", () => { expect(policy.validateAnthropicTurns).toBe(true); }); + it("enables Anthropic-compatible policies for Bedrock provider", () => { + const policy = resolveTranscriptPolicy({ + provider: "amazon-bedrock", + modelId: "us.anthropic.claude-opus-4-6-v1", + modelApi: "bedrock-converse-stream", + }); + expect(policy.repairToolUseResultPairing).toBe(true); + expect(policy.validateAnthropicTurns).toBe(true); + expect(policy.allowSyntheticToolResults).toBe(true); + expect(policy.sanitizeToolCallIds).toBe(true); + expect(policy.sanitizeMode).toBe("full"); + }); + it("keeps OpenRouter on its existing turn-validation path", () => { const policy = resolveTranscriptPolicy({ provider: "openrouter", diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 0672bf1e840..3b1d6aa1db4 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -55,12 +55,12 @@ function isOpenAiProvider(provider?: string | null): boolean { } function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { - if (modelApi === "anthropic-messages") { + if (modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream") { return true; } const normalized = normalizeProviderId(provider ?? ""); // MiniMax now uses openai-completions API, not anthropic-messages - return normalized === "anthropic"; + return normalized === "anthropic" || normalized === "amazon-bedrock"; } function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { From 58ce0a89ecf1d44e9e58452e9c7d2fb775f02f4e Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Mon, 23 Feb 2026 10:42:05 -0300 Subject: [PATCH 280/314] fix(cli): load plugin registry for configure and onboard commands (#17266) (cherry picked from commit 644badd40df6eb36847ee7baf36e02ae07bdac74) --- src/cli/program/preaction.test.ts | 20 ++++++++++++++++++++ src/cli/program/preaction.ts | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index c583d2c83cf..bf4184d362a 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -80,6 +80,8 @@ describe("registerPreActionHooks", () => { program.command("update").action(async () => {}); program.command("channels").action(async () => {}); program.command("directory").action(async () => {}); + program.command("configure").action(async () => {}); + program.command("onboard").action(async () => {}); program .command("message") .command("send") @@ -125,6 +127,24 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); }); + it("loads plugin registry for configure command", async () => { + await runCommand({ + parseArgv: ["configure"], + processArgv: ["node", "openclaw", "configure"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + + it("loads plugin registry for onboard command", async () => { + await runCommand({ + parseArgv: ["onboard"], + processArgv: ["node", "openclaw", "onboard"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + }); + it("skips config guard for doctor and completion commands", async () => { await runCommand({ parseArgv: ["doctor"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 3e0580154bd..6a9abc3e99e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -21,7 +21,13 @@ function setProcessTitleForCommand(actionCommand: Command) { } // Commands that need channel plugins loaded -const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]); +const PLUGIN_REQUIRED_COMMANDS = new Set([ + "message", + "channels", + "directory", + "configure", + "onboard", +]); function getRootCommand(command: Command): Command { let current = command; From 75969ed5c499a1a45d9cfcbaaf999567fc4d9c2e Mon Sep 17 00:00:00 2001 From: Marc Gratch Date: Mon, 23 Feb 2026 11:43:52 -0600 Subject: [PATCH 281/314] fix(plugins): pass session context to before_compaction hook in subscribe handler The handleAutoCompactionStart handler was calling runBeforeCompaction with only messageCount and an empty hook context. Plugins receiving this hook could not identify the session or snapshot the transcript during auto-compaction. The other call site in compact.ts already passes the full payload (messages, sessionFile, sessionKey). This aligns the subscribe handler to do the same using ctx.params.session and ctx.params.sessionKey. (cherry picked from commit 318a19d1a1a428ff1be2e03f51777c3829c6e322) --- .../pi-embedded-subscribe.handlers.compaction.ts | 6 +++++- src/plugins/wired-hooks-compaction.test.ts | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts index a9dda4110e0..a8072bf2e1a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts @@ -24,8 +24,12 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) { .runBeforeCompaction( { messageCount: ctx.params.session.messages?.length ?? 0, + messages: ctx.params.session.messages, + sessionFile: ctx.params.session.sessionFile, + }, + { + sessionKey: ctx.params.sessionKey, }, - {}, ) .catch((err) => { ctx.log.warn(`before_compaction hook failed: ${String(err)}`); diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts index 2292d95b760..05e63a2b2f9 100644 --- a/src/plugins/wired-hooks-compaction.test.ts +++ b/src/plugins/wired-hooks-compaction.test.ts @@ -41,7 +41,11 @@ describe("compaction hook wiring", () => { hookMocks.runner.hasHooks.mockReturnValue(true); const ctx = { - params: { runId: "r1", session: { messages: [1, 2, 3] } }, + params: { + runId: "r1", + sessionKey: "agent:main:web-abc123", + session: { messages: [1, 2, 3], sessionFile: "/tmp/test.jsonl" }, + }, state: { compactionInFlight: false }, log: { debug: vi.fn(), warn: vi.fn() }, incrementCompactionCount: vi.fn(), @@ -53,10 +57,16 @@ describe("compaction hook wiring", () => { expect(hookMocks.runner.runBeforeCompaction).toHaveBeenCalledTimes(1); const beforeCalls = hookMocks.runner.runBeforeCompaction.mock.calls as unknown as Array< - [unknown] + [unknown, unknown] >; - const event = beforeCalls[0]?.[0] as { messageCount?: number } | undefined; + const event = beforeCalls[0]?.[0] as + | { messageCount?: number; messages?: unknown[]; sessionFile?: string } + | undefined; expect(event?.messageCount).toBe(3); + expect(event?.messages).toEqual([1, 2, 3]); + expect(event?.sessionFile).toBe("/tmp/test.jsonl"); + const hookCtx = beforeCalls[0]?.[1] as { sessionKey?: string } | undefined; + expect(hookCtx?.sessionKey).toBe("agent:main:web-abc123"); }); it("calls runAfterCompaction when willRetry is false", () => { From 8c8374defa4d670e62236ba2a161ff009462b1f8 Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 23 Feb 2026 19:16:57 +0000 Subject: [PATCH 282/314] fix(cron): treat embedded error payloads as run failures (cherry picked from commit 50fd31c070e8b466db6d81c70b285fd631df1c05) --- ....uses-last-non-empty-agent-text-as.test.ts | 27 +++++++++++++++++-- src/cron/isolated-agent/run.ts | 26 ++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts index abb27177a54..353d92e1b85 100644 --- a/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts +++ b/src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts @@ -27,9 +27,9 @@ function makeDeps(): CliDeps { }; } -function mockEmbeddedTexts(texts: string[]) { +function mockEmbeddedPayloads(payloads: Array<{ text?: string; isError?: boolean }>) { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: texts.map((text) => ({ text })), + payloads, meta: { durationMs: 5, agentMeta: { sessionId: "s", provider: "p", model: "m" }, @@ -37,6 +37,10 @@ function mockEmbeddedTexts(texts: string[]) { }); } +function mockEmbeddedTexts(texts: string[]) { + mockEmbeddedPayloads(texts.map((text) => ({ text }))); +} + function mockEmbeddedOk() { mockEmbeddedTexts(["ok"]); } @@ -174,6 +178,25 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("returns error when embedded run payload is marked as error", async () => { + await withTempHome(async (home) => { + mockEmbeddedPayloads([ + { + text: "⚠️ 🛠️ Exec failed: /bin/bash: line 1: python: command not found", + isError: true, + }, + ]); + const { res } = await runCronTurn(home, { + jobPayload: DEFAULT_AGENT_TURN_PAYLOAD, + mockTexts: null, + }); + + expect(res.status).toBe("error"); + expect(res.error).toContain("command not found"); + expect(res.summary).toContain("Exec failed"); + }); + }); + it("passes resolved agentDir to runEmbeddedPiAgent", async () => { await withTempHome(async (home) => { const { res } = await runCronTurn(home, { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ea6c819e253..bfc37d48249 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -543,6 +543,25 @@ export async function runCronIsolatedAgentTurn(params: { (deliveryPayload?.mediaUrls?.length ?? 0) > 0 || Object.keys(deliveryPayload?.channelData ?? {}).length > 0; const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); + const hasErrorPayload = payloads.some((payload) => payload?.isError === true); + const lastErrorPayloadText = [...payloads] + .toReversed() + .find((payload) => payload?.isError === true && Boolean(payload?.text?.trim())) + ?.text?.trim(); + const embeddedRunError = hasErrorPayload + ? (lastErrorPayloadText ?? "cron isolated run returned an error payload") + : undefined; + const resolveRunOutcome = (params?: { delivered?: boolean }) => + withRunSession({ + status: hasErrorPayload ? "error" : "ok", + ...(hasErrorPayload + ? { error: embeddedRunError ?? "cron isolated run returned an error payload" } + : {}), + summary, + outputText, + delivered: params?.delivered, + ...telemetry, + }); // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); @@ -586,11 +605,14 @@ export async function runCronIsolatedAgentTurn(params: { withRunSession, }); if (deliveryResult.result) { - return deliveryResult.result; + if (!hasErrorPayload || deliveryResult.result.status !== "ok") { + return deliveryResult.result; + } + return resolveRunOutcome({ delivered: deliveryResult.result.delivered }); } const delivered = deliveryResult.delivered; summary = deliveryResult.summary; outputText = deliveryResult.outputText; - return withRunSession({ status: "ok", summary, outputText, delivered, ...telemetry }); + return resolveRunOutcome({ delivered }); } From 424ba72cad2402f5d0556fcf8456ccad4904a7b0 Mon Sep 17 00:00:00 2001 From: chilu18 Date: Mon, 23 Feb 2026 20:18:26 +0000 Subject: [PATCH 283/314] fix(config): add actionable guidance for dmPolicy open allowFrom mismatch (cherry picked from commit d3bfbdec5dc5c85305caa0f129f5d4b3c504f559) --- src/config/io.ts | 27 ++++++++++++++++++++++++++- src/config/io.write-config.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/config/io.ts b/src/config/io.ts index 8dbcf10936c..01e691f1e60 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -72,6 +72,9 @@ const SHELL_ENV_EXPECTED_KEYS = [ "OPENCLAW_GATEWAY_PASSWORD", ]; +const OPEN_DM_POLICY_ALLOW_FROM_RE = + /^(?[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i; + const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl"; const loggedInvalidConfigs = new Set(); @@ -137,6 +140,27 @@ function hashConfigRaw(raw: string | null): string { .digest("hex"); } +function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string { + const match = issueMessage.match(OPEN_DM_POLICY_ALLOW_FROM_RE); + const policyPath = match?.groups?.policyPath?.trim(); + const allowPath = match?.groups?.allowPath?.trim(); + if (!policyPath || !allowPath) { + return `Config validation failed: ${pathLabel}: ${issueMessage}`; + } + + return [ + `Config validation failed: ${pathLabel}`, + "", + `Configuration mismatch: ${policyPath} is "open", but ${allowPath} does not include "*".`, + "", + "Fix with:", + ` openclaw config set ${allowPath} '["*"]'`, + "", + "Or switch policy:", + ` openclaw config set ${policyPath} "pairing"`, + ].join("\n"); +} + function isNumericPathSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); } @@ -1019,7 +1043,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (!validated.ok) { const issue = validated.issues[0]; const pathLabel = issue?.path ? issue.path : ""; - throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`); + const issueMessage = issue?.message ?? "invalid"; + throw new Error(formatConfigValidationFailure(pathLabel, issueMessage)); } if (validated.warnings.length > 0) { const details = validated.warnings diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index d8ac2bbc280..20a9ffc020d 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -125,6 +125,32 @@ describe("config io write", () => { }); }); + it('shows actionable guidance for dmPolicy="open" without wildcard allowFrom', async () => { + await withTempHome("openclaw-config-io-", async (home) => { + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const invalidConfig = { + channels: { + telegram: { + dmPolicy: "open", + allowFrom: [], + }, + }, + }; + + await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( + "openclaw config set channels.telegram.allowFrom '[\"*\"]'", + ); + await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( + 'openclaw config set channels.telegram.dmPolicy "pairing"', + ); + }); + }); + it("honors explicit unset paths when schema defaults would otherwise reappear", async () => { await withTempHome("openclaw-config-io-", async (home) => { const { configPath, io, snapshot } = await writeConfigAndCreateIo({ From 252079f0013467169def8be63dd062a360280fe0 Mon Sep 17 00:00:00 2001 From: Ben Marvell Date: Mon, 23 Feb 2026 14:12:11 +0000 Subject: [PATCH 284/314] fix(agents): repair orphaned tool results for OpenAI after history truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repairToolUseResultPairing was gated behind !isOpenAi, skipping orphaned tool_result cleanup for OpenAI providers. When limitHistoryTurns truncated conversation history, tool_result messages whose matching tool_call was before the truncation point survived and were sent as function_call_output items with stale call_id references. OpenAI rejects these with: "No tool call found for function call output with call_id ..." Enable the repair universally — all providers need it after truncation. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 97b065aa6e56fff97414bee26a6b6fc5a33f019a) --- src/agents/transcript-policy.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 3b1d6aa1db4..baa12eda96a 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -108,7 +108,10 @@ export function resolveTranscriptPolicy(params: { : sanitizeToolCallIds ? "strict" : undefined; - const repairToolUseResultPairing = isGoogle || isAnthropic; + // All providers need orphaned tool_result repair after history truncation. + // OpenAI rejects function_call_output items whose call_id has no matching + // function_call in the conversation, so the repair must run universally. + const repairToolUseResultPairing = true; const sanitizeThoughtSignatures = isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; @@ -116,7 +119,7 @@ export function resolveTranscriptPolicy(params: { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, toolCallIdMode, - repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing, + repairToolUseResultPairing, preserveSignatures: false, sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, sanitizeThinkingSignatures: false, From eae13d9367676c278f5e904fbfe715d81f55521d Mon Sep 17 00:00:00 2001 From: Ben Marvell Date: Mon, 23 Feb 2026 14:37:56 +0000 Subject: [PATCH 285/314] test(agents): update test to match universal tool-result repair for OpenAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous test asserted that OpenAI-responses sessions would NOT get synthetic tool results for orphaned tool calls. With repairToolUseResultPairing now running universally, the correct behavior is that orphaned tool calls get a synthetic tool_result — matching what OpenAI actually requires. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 2edb0ffe0bf96e9e415c03458ff9cee6bf29bcbe) --- .../pi-embedded-runner.sanitize-session-history.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index e9cd5065d3d..6e401b92e0a 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -298,7 +298,7 @@ describe("sanitizeSessionHistory", () => { expect(result[1]?.role).toBe("assistant"); }); - it("does not synthesize tool results for openai-responses", async () => { + it("synthesizes missing tool results for openai-responses after repair", async () => { const messages = [ { role: "assistant", @@ -314,8 +314,11 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(result).toHaveLength(1); + // repairToolUseResultPairing now runs for all providers (including OpenAI) + // to fix orphaned function_call_output items that OpenAI would reject. + expect(result).toHaveLength(2); expect(result[0]?.role).toBe("assistant"); + expect(result[1]?.role).toBe("toolResult"); }); it("drops malformed tool calls missing input or arguments", async () => { From bc52d4a459b1d546e87981fb7a1bc0e163bbdb71 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 24 Feb 2026 04:36:20 +0100 Subject: [PATCH 286/314] fix(openrouter): skip reasoning effort injection for 'auto' routing model The 'auto' model on OpenRouter dynamically routes to any underlying model OpenRouter selects, including reasoning-required endpoints. Previously, OpenClaw would unconditionally inject `reasoning.effort: "none"` into every request when the thinking level was "off", which causes a 400 error on models where reasoning is mandatory and cannot be disabled. Root cause: - openrouter/auto has reasoning: false in the built-in catalog - With thinking level "off", createOpenRouterWrapper injects `reasoning: { effort: "none" }` via mapThinkingLevelToOpenRouterReasoningEffort - For any OpenRouter-routed model that requires reasoning this results in: "400 Reasoning is mandatory for this endpoint and cannot be disabled" - The reasoning: false is then persisted back to models.json on every ensureOpenClawModelsJson call, so manually removing it has no lasting effect Fix: - In applyExtraParamsToAgent, when provider is "openrouter" and the model id is "auto", pass undefined as thinkingLevel to createOpenRouterWrapper so no reasoning.effort is injected at all, letting OpenRouter's upstream model handle it natively - Add an explanatory comment in buildOpenrouterProvider clarifying that the reasoning: false catalog value does NOT cause effort injection for "auto" Users who need explicit reasoning control should target a specific model id (e.g. openrouter/deepseek/deepseek-r1) rather than the auto router. Fixes #24851 (cherry picked from commit aa554397980972d917dece09ab03c4cc15f5d100) --- src/agents/models-config.providers.ts | 6 ++++++ src/agents/pi-embedded-runner/extra-params.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 3662ce9a3b1..4f921b6dd81 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -685,6 +685,12 @@ function buildOpenrouterProvider(): ProviderConfig { { id: OPENROUTER_DEFAULT_MODEL_ID, name: "OpenRouter Auto", + // reasoning: false here is a catalog default only; it does NOT cause + // `reasoning.effort: "none"` to be sent for the "auto" routing model. + // applyExtraParamsToAgent skips the reasoning effort injection for + // model id "auto" because it dynamically routes to any OpenRouter model + // (including ones where reasoning is mandatory and cannot be disabled). + // See: openclaw/openclaw#24851 reasoning: false, input: ["text", "image"], cost: OPENROUTER_DEFAULT_COST, diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 66b077af232..0d88bdf08f3 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -546,7 +546,14 @@ export function applyExtraParamsToAgent( if (provider === "openrouter") { log.debug(`applying OpenRouter app attribution headers for ${provider}/${modelId}`); - agent.streamFn = createOpenRouterWrapper(agent.streamFn, thinkingLevel); + // "auto" is a dynamic routing model — we don't know which underlying model + // OpenRouter will select, and it may be a reasoning-required endpoint. + // Omit the thinkingLevel so we never inject `reasoning.effort: "none"`, + // which would cause a 400 on models where reasoning is mandatory. + // Users who need reasoning control should target a specific model ID. + // See: openclaw/openclaw#24851 + const openRouterThinkingLevel = modelId === "auto" ? undefined : thinkingLevel; + agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel); agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn); } From 83689fc83837ac80837705cfd4b81aae0214afeb Mon Sep 17 00:00:00 2001 From: Marco Di Dionisio Date: Mon, 23 Feb 2026 19:20:26 +0100 Subject: [PATCH 287/314] fix: include trusted-proxy in sharedAuthOk check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In trusted-proxy mode, sharedAuthResult is null because hasSharedAuth only triggers for token/password in connectParams.auth. But the primary auth (authResult) already validated the trusted-proxy — the connection came from a CIDR in trustedProxies with a valid userHeader. This IS shared auth semantically (the proxy vouches for identity), so operator connections should be able to skip device identity. Without this fix, trusted-proxy operator connections are rejected with "device identity required" because roleCanSkipDeviceIdentity() sees sharedAuthOk=false. (cherry picked from commit e87048a6a650d391e1eb5704546eb49fac5f0091) --- src/gateway/server/ws-connection/auth-context.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index d5e98dfd533..cb797772288 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -133,9 +133,13 @@ export async function resolveConnectAuthState(params: { // primary auth flow (or deferred for device-token candidates). rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, })); + // Trusted-proxy auth is semantically shared: the proxy vouches for identity, + // no per-device credential needed. Include it so operator connections + // can skip device identity via roleCanSkipDeviceIdentity(). const sharedAuthOk = - sharedAuthResult?.ok === true && - (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); + (sharedAuthResult?.ok === true && + (sharedAuthResult.method === "token" || sharedAuthResult.method === "password")) || + (authResult.ok && authResult.method === "trusted-proxy"); return { authResult, From a7518b75894ed4745953fb2b1371f7a9a2c3e445 Mon Sep 17 00:00:00 2001 From: Shennan Date: Tue, 24 Feb 2026 01:48:51 +0800 Subject: [PATCH 288/314] fix(feishu): pass parentPeer for topic session binding inheritance (cherry picked from commit bddeb1fd95d10cf18da9dca129b58828eae84cba) --- extensions/feishu/src/bot.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 91d390ac04d..f18658e62b5 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -720,10 +720,10 @@ export async function handleFeishuMessage(params: { // When topicSessionMode is enabled, messages within a topic (identified by root_id) // get a separate session from the main group chat. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; + let topicSessionMode: "enabled" | "disabled" = "disabled"; if (isGroup && ctx.rootId) { const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); - const topicSessionMode = - groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; + topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; if (topicSessionMode === "enabled") { // Use chatId:topic:rootId as peer ID for topic-scoped sessions peerId = `${ctx.chatId}:topic:${ctx.rootId}`; @@ -739,6 +739,14 @@ export async function handleFeishuMessage(params: { kind: isGroup ? "group" : "direct", id: peerId, }, + // Add parentPeer for binding inheritance in topic mode + parentPeer: + isGroup && ctx.rootId && topicSessionMode === "enabled" + ? { + kind: "group", + id: ctx.chatId, + } + : null, }); // Dynamic agent creation for DM users From b9e587fb63ddec9d429ffd1c6fa68a55be59b9ec Mon Sep 17 00:00:00 2001 From: Workweaver Ralph Date: Tue, 24 Feb 2026 06:33:17 +0530 Subject: [PATCH 289/314] fix(tui): guard sendMessage when disconnected; reset readyPromise on close (cherry picked from commit df827c3eef34ca02cfe5c57a1eabcd9c8e5a4ec1) --- src/tui/gateway-chat.ts | 4 ++++ src/tui/tui-command-handlers.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 5cbec2e0299..f55bbf5f354 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -146,6 +146,10 @@ export class GatewayChatClient { }); }, onClose: (_code, reason) => { + // Reset so waitForReady() blocks again until the next successful reconnect. + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); this.onDisconnected?.(reason); }, onGap: (info) => { diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 4e5a56f6238..989c942beb6 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -456,6 +456,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { }; const sendMessage = async (text: string) => { + if (!state.isConnected) { + chatLog.addSystem("not connected to gateway — message not sent"); + setActivityStatus("disconnected"); + tui.requestRender(); + return; + } try { chatLog.addUser(text); tui.requestRender(); From 7d76c241f89c5fbd4eca173e002dc24c765e07ab Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 11:11:41 +0800 Subject: [PATCH 290/314] fix: suppress reasoning payloads from generic channel dispatch path When reasoningLevel is 'on', reasoning content was being sent as a visible message to WhatsApp and other non-Telegram channels via two paths: 1. Block reply: emitted via onBlockReply in handleMessageEnd 2. Final payloads: added to replyItems in buildEmbeddedRunPayloads Telegram has its own dispatch path (bot-message-dispatch.ts) that splits reasoning into a dedicated lane and handles suppression. The generic dispatch-from-config.ts path used by WhatsApp, web, etc. had no such filtering. Fix: - Add isReasoning?: boolean flag to ReplyPayload - Tag reasoning payloads at both emission points - Filter isReasoning payloads in dispatch-from-config.ts for both block reply and final reply paths Telegram is unaffected: it uses its own deliver callback that detects reasoning via the 'Reasoning:\n' prefix and routes to a separate lane. Fixes #24954 --- src/agents/pi-embedded-runner/run/payloads.ts | 2 +- ...pi-embedded-subscribe.handlers.messages.ts | 2 +- .../reply/dispatch-from-config.test.ts | 43 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 11 +++++ src/auto-reply/types.ts | 3 ++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 7b3d40c5d00..d4ee6dc0763 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -187,7 +187,7 @@ export function buildEmbeddedRunPayloads(params: { ? formatReasoningMessage(extractAssistantThinking(params.lastAssistant)) : ""; if (reasoningText) { - replyItems.push({ text: reasoningText }); + replyItems.push({ text: reasoningText, isReasoning: true }); } const fallbackAnswerText = params.lastAssistant ? extractAssistantText(params.lastAssistant) : ""; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 845ded9f9b9..a32c9fdf219 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -339,7 +339,7 @@ export function handleMessageEnd( return; } ctx.state.lastReasoningSent = formattedReasoning; - void onBlockReply?.({ text: formattedReasoning }); + void onBlockReply?.({ text: formattedReasoning, isReasoning: true }); }; if (shouldEmitReasoningBeforeAnswer) { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 2a69f506a7f..bd1715bf511 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -538,4 +538,47 @@ describe("dispatchReplyFromConfig", () => { }), ); }); + + it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const replyResolver = async () => + [ + { text: "Reasoning:\n_thinking..._", isReasoning: true }, + { text: "The answer is 42" }, + ] satisfies ReplyPayload[]; + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; + expect(finalCalls).toHaveLength(1); + expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" }); + }); + + it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "whatsapp" }); + const blockReplySentTexts: string[] = []; + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + ): Promise => { + // Simulate block reply with reasoning payload + await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); + await opts?.onBlockReply?.({ text: "The answer is 42" }); + return { text: "The answer is 42" }; + }; + // Capture what actually gets dispatched as block replies + (dispatcher.sendBlockReply as ReturnType).mockImplementation( + (payload: ReplyPayload) => { + if (payload.text) { + blockReplySentTexts.push(payload.text); + } + return true; + }, + ); + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); + expect(blockReplySentTexts).toContain("The answer is 42"); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index e4e66c16a57..96989ff98ea 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -363,6 +363,12 @@ export async function dispatchReplyFromConfig(params: { }, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { + // Suppress reasoning payloads — channels using this generic dispatch + // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. + // Telegram has its own dispatch path that handles reasoning splitting. + if (payload.isReasoning) { + return; + } // Accumulate block text for TTS generation after streaming if (payload.text) { if (accumulatedBlockText.length > 0) { @@ -396,6 +402,11 @@ export async function dispatchReplyFromConfig(params: { let queuedFinal = false; let routedFinalCount = 0; for (const reply of replies) { + // Suppress reasoning payloads from channel delivery — channels using this + // generic dispatch path do not have a dedicated reasoning lane. + if (reply.isReasoning) { + continue; + } const ttsReply = await maybeApplyTtsToPayload({ payload: reply, cfg, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 839fac55977..f522e31042f 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -66,6 +66,9 @@ export type ReplyPayload = { /** Send audio as voice message (bubble) instead of audio file. Defaults to false. */ audioAsVoice?: boolean; isError?: boolean; + /** Marks this payload as a reasoning/thinking block. Channels that do not + * have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */ + isReasoning?: boolean; /** Channel-specific payload data (per-channel envelope). */ channelData?: Record; }; From d427d09b5ee041e4bd90fc351993017fa6114030 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 03:50:27 +0000 Subject: [PATCH 291/314] fix: align reasoning payload typing for #24991 (thanks @stakeswky) --- CHANGELOG.md | 1 + src/agents/pi-embedded-payloads.ts | 1 + src/agents/pi-embedded-runner/run/payloads.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1efde75b9f..41d53e20462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. +- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. - Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. - Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. diff --git a/src/agents/pi-embedded-payloads.ts b/src/agents/pi-embedded-payloads.ts index 1be29b5a3af..1186111db10 100644 --- a/src/agents/pi-embedded-payloads.ts +++ b/src/agents/pi-embedded-payloads.ts @@ -2,6 +2,7 @@ export type BlockReplyPayload = { text?: string; mediaUrls?: string[]; audioAsVoice?: boolean; + isReasoning?: boolean; replyToId?: string; replyToTag?: boolean; replyToCurrent?: boolean; diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index d4ee6dc0763..c3c87845451 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -108,6 +108,7 @@ export function buildEmbeddedRunPayloads(params: { mediaUrls?: string[]; replyToId?: string; isError?: boolean; + isReasoning?: boolean; audioAsVoice?: boolean; replyToTag?: boolean; replyToCurrent?: boolean; @@ -116,6 +117,7 @@ export function buildEmbeddedRunPayloads(params: { text: string; media?: string[]; isError?: boolean; + isReasoning?: boolean; audioAsVoice?: boolean; replyToId?: string; replyToTag?: boolean; From 19d0ddc679d54f13e5af1ff250b85fb46f21b485 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:05:03 +0000 Subject: [PATCH 292/314] fix: regenerate protocol swift models for nodeId (#24991) (thanks @stakeswky) --- apps/macos/Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ .../OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index af7b1ccafdc..4e766514def 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2806,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2819,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2831,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2845,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index af7b1ccafdc..4e766514def 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2806,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2819,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2831,6 +2833,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2845,6 +2848,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { case id case command case cwd + case nodeid = "nodeId" case host case security case ask From 2880fb3cb89a4bcb54e6900ce82a0b51e275284d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:07:44 +0000 Subject: [PATCH 293/314] fix: sync lockfile for diagnostics-otel deps (#24991) (thanks @stakeswky) --- pnpm-lock.yaml | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85fe19921d7..9eb4bc69db0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,12 +322,6 @@ importers: specifier: workspace:* version: link:../.. - extensions/google-antigravity-auth: - devDependencies: - openclaw: - specifier: workspace:* - version: link:../.. - extensions/google-gemini-cli-auth: devDependencies: openclaw: @@ -6890,7 +6884,7 @@ snapshots: '@larksuiteoapi/node-sdk@1.59.0': dependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) lodash.identity: 3.0.0 lodash.merge: 4.6.2 lodash.pickby: 4.6.0 @@ -6906,7 +6900,7 @@ snapshots: dependencies: '@types/node': 24.10.13 optionalDependencies: - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: - debug @@ -7095,7 +7089,7 @@ snapshots: '@azure/core-auth': 1.10.1 '@azure/msal-node': 5.0.4 '@microsoft/agents-activity': 1.3.1 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.2 object-path: 0.11.8 @@ -7997,7 +7991,7 @@ snapshots: '@slack/types': 2.20.0 '@slack/web-api': 7.14.1 '@types/express': 5.0.6 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) express: 5.2.1 path-to-regexp: 8.3.0 raw-body: 3.0.2 @@ -8043,7 +8037,7 @@ snapshots: '@slack/types': 2.20.0 '@types/node': 25.3.0 '@types/retry': 0.12.0 - axios: 1.13.5 + axios: 1.13.5(debug@4.4.3) eventemitter3: 5.0.4 form-data: 2.5.4 is-electron: 2.2.2 @@ -8935,14 +8929,6 @@ snapshots: aws4@1.13.2: {} - axios@1.13.5: - dependencies: - follow-redirects: 1.15.11 - form-data: 2.5.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) @@ -9512,8 +9498,6 @@ snapshots: flatbuffers@24.12.23: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: debug: 4.4.3 From cb450fd31f0858601de9b1254db525dd8f022da3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:09:26 +0000 Subject: [PATCH 294/314] fix: align lockfile with diagnostics-otel proto deps (#24991) (thanks @stakeswky) --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eb4bc69db0..a8c7b81bb33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,13 +268,13 @@ importers: '@opentelemetry/api-logs': specifier: ^0.212.0 version: 0.212.0 - '@opentelemetry/exporter-logs-otlp-http': + '@opentelemetry/exporter-logs-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': + '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': + '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.212.0 version: 0.212.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': From d3ecc234da1729b19f7c9cd427aff0fb0d3ab15f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:19:12 +0000 Subject: [PATCH 295/314] test: align flaky CI expectations after main changes (#24991) (thanks @stakeswky) --- src/agents/sandbox/fs-bridge.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index ca4dd9d62bb..f1d72be03b6 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -118,9 +118,11 @@ describe("sandbox fs bridge shell compatibility", () => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fs-bridge-")); const workspaceDir = path.join(stateDir, "workspace"); const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "secret.txt"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(outsideDir, { recursive: true }); - await fs.symlink(path.join(outsideDir, "secret.txt"), path.join(workspaceDir, "link.txt")); + await fs.writeFile(outsideFile, "classified"); + await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt")); const bridge = createSandboxFsBridge({ sandbox: createSandbox({ From 5ac70b36a4b6303fc1eff5f04a2737af3e729a23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:33:53 +0000 Subject: [PATCH 296/314] test: make shell-env trust-path test platform-safe (#24991) (thanks @stakeswky) --- src/infra/shell-env.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 644948b03c9..1696028b39d 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -199,8 +199,11 @@ describe("shell env fallback", () => { }); it("uses SHELL when it is explicitly registered in /etc/shells", () => { - withEtcShells(["/bin/sh", "/usr/bin/zsh-trusted"], () => { - const trustedShell = "/usr/bin/zsh-trusted"; + const trustedShell = + process.platform === "win32" + ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + : "/usr/bin/zsh-trusted"; + withEtcShells(["/bin/sh", trustedShell], () => { const { res, exec } = runShellEnvFallbackForShell(trustedShell); expect(res.ok).toBe(true); From 1298bd4e1bdb7cd28dea6ddd8e3de7f2f43be36d Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 17:41:12 +0000 Subject: [PATCH 297/314] fix(matrix): skip reasoning-only messages in reply delivery When `includeReasoning` is active (or `reasoningLevel` falls back to the model default), the agent emits reasoning blocks as separate reply payloads prefixed with "Reasoning:\n". Matrix has no dedicated reasoning lane, so these internal thinking traces leak into the chat as regular user-visible messages. Filter out pure-reasoning payloads (those starting with "Reasoning:\n" or a `` tag) before delivery so internal reasoning never reaches the Matrix room. Fixes #24411 Co-Authored-By: Claude Opus 4.6 --- .../matrix/src/matrix/monitor/replies.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 643e95cd413..c86c7dde688 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } + // Skip pure reasoning messages so internal thinking traces are never delivered. + if (reply.text && isReasoningOnlyMessage(reply.text)) { + logVerbose("matrix reply is reasoning-only; skipping"); + continue; + } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; const rawText = reply.text ?? ""; @@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: { } } } + +const REASONING_PREFIX = "Reasoning:\n"; +const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i; + +/** + * Detect messages that contain only reasoning/thinking content and no user-facing answer. + * These are emitted by the agent when `includeReasoning` is active but should not + * be forwarded to channels that do not support a dedicated reasoning lane. + */ +function isReasoningOnlyMessage(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.startsWith(REASONING_PREFIX)) { + return true; + } + if (THINKING_TAG_RE.test(trimmed)) { + return true; + } + return false; +} From 0ded77ca7d0003ae2210f506d5cababf1f4b8902 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 20:06:07 +0000 Subject: [PATCH 298/314] test(matrix): add regression tests for reasoning-only reply filtering Verify that deliverMatrixReplies skips replies whose text starts with "Reasoning:\n" or opens with // tags, while still delivering all normal replies. Co-Authored-By: Claude Opus 4.6 --- .../matrix/src/matrix/monitor/replies.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 3dda8fac9b5..dfbfbabb8af 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => { ); }); + it("skips reasoning-only replies with Reasoning prefix", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" }, + { text: "Here is the answer.", replyToId: "r2" }, + ], + roomId: "room:reason", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "first", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer."); + }); + + it("skips reasoning-only replies with thinking tags", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "internal chain of thought", replyToId: "r1" }, + { text: " more reasoning ", replyToId: "r2" }, + { text: "hidden", replyToId: "r3" }, + { text: "Visible reply", replyToId: "r4" }, + ], + roomId: "room:tags", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply"); + }); + + it("delivers all replies when none are reasoning-only", async () => { + await deliverMatrixReplies({ + replies: [ + { text: "First answer", replyToId: "r1" }, + { text: "Second answer", replyToId: "r2" }, + ], + roomId: "room:normal", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + }); + it("suppresses replyToId when threadId is set", async () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); From e8a4d5d9bd578509a2f497ec231d7d25167b8e9d Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 17:30:59 +0000 Subject: [PATCH 299/314] fix(discord): strip reasoning tags from partial stream preview When streamMode is "partial", reasoning/thinking block content can leak into the Discord draft preview because the partial text is forwarded to the draft stream without filtering. Apply `stripReasoningTagsFromText` before updating the draft and skip pure-reasoning messages (those starting with "Reasoning:\n") so internal thinking traces never reach the user-visible preview. Fixes #24532 Co-Authored-By: Claude Opus 4.6 --- .../monitor/message-handler.process.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 1c41fef76ec..60966cff3cc 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -30,6 +30,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; @@ -485,7 +486,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (!draftStream || !text) { return; } - if (text === lastPartialText) { + // Strip reasoning/thinking tags that may leak through the stream. + const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" }); + // Skip pure-reasoning messages (e.g. "Reasoning:\n…") that contain no answer text. + if (!cleaned || cleaned.startsWith("Reasoning:\n")) { + return; + } + if (cleaned === lastPartialText) { return; } hasStreamedMessage = true; @@ -493,30 +500,30 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // Keep the longer preview to avoid visible punctuation flicker. if ( lastPartialText && - lastPartialText.startsWith(text) && - text.length < lastPartialText.length + lastPartialText.startsWith(cleaned) && + cleaned.length < lastPartialText.length ) { return; } - lastPartialText = text; - draftStream.update(text); + lastPartialText = cleaned; + draftStream.update(cleaned); return; } - let delta = text; - if (text.startsWith(lastPartialText)) { - delta = text.slice(lastPartialText.length); + let delta = cleaned; + if (cleaned.startsWith(lastPartialText)) { + delta = cleaned.slice(lastPartialText.length); } else { // Streaming buffer reset (or non-monotonic stream). Start fresh. draftChunker?.reset(); draftText = ""; } - lastPartialText = text; + lastPartialText = cleaned; if (!delta) { return; } if (!draftChunker) { - draftText = text; + draftText = cleaned; draftStream.update(draftText); return; } From 6ea1607f1c5ec1d7dbee0c995f2e983f09234d15 Mon Sep 17 00:00:00 2001 From: justinhuangcode Date: Mon, 23 Feb 2026 20:07:30 +0000 Subject: [PATCH 300/314] test(discord): add regression tests for reasoning tag stripping in stream Verify that partial stream updates containing tags are stripped before reaching the draft preview, and that pure "Reasoning:\n" partials are suppressed entirely. Co-Authored-By: Claude Opus 4.6 --- .../monitor/message-handler.process.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 067273351db..482f61cfc3f 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -476,4 +476,49 @@ describe("processDiscordMessage draft streaming", () => { expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1); }); + + it("strips reasoning tags from partial stream updates", async () => { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onPartialReply?.({ + text: "Let me think about this\nThe answer is 42", + }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + const updates = draftStream.update.mock.calls.map((call) => call[0]); + for (const text of updates) { + expect(text).not.toContain(""); + } + }); + + it("skips pure-reasoning partial updates without updating draft", async () => { + const draftStream = createMockDraftStream(); + createDiscordDraftStream.mockReturnValueOnce(draftStream); + + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onPartialReply?.({ + text: "Reasoning:\nThe user asked about X so I need to consider Y", + }); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(draftStream.update).not.toHaveBeenCalled(); + }); }); From 2d6d6797d81a2534bff1aa9ee4f3c0cd2dd18013 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:38:04 +0000 Subject: [PATCH 301/314] test: fix post-merge config and tui command-handler tests --- .../browser-cli-state.option-collisions.test.ts | 12 +++++++++++- src/config/io.write-config.test.ts | 3 ++- src/tui/tui-command-handlers.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 45ec5c6a5c1..917c6c4551e 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -85,7 +85,17 @@ describe("browser state option collisions", () => { it("resolves --url via parent when addGatewayClientOptions captures it", async () => { const program = createBrowserProgram({ withGatewayUrl: true }); await program.parseAsync( - ["browser", "--url", "ws://gw", "cookies", "set", "session", "abc", "--url", "https://example.com"], + [ + "browser", + "--url", + "ws://gw", + "cookies", + "set", + "session", + "abc", + "--url", + "https://example.com", + ], { from: "user" }, ); const call = mocks.callBrowserRequest.mock.calls.at(-1); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 20a9ffc020d..19bb776b49a 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "./home-env.test-harness.js"; import { createConfigIO } from "./io.js"; +import type { OpenClawConfig } from "./types.js"; describe("config io write", () => { const silentLogger = { @@ -140,7 +141,7 @@ describe("config io write", () => { allowFrom: [], }, }, - }; + } satisfies OpenClawConfig; await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow( "openclaw config set channels.telegram.allowFrom '[\"*\"]'", diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index f9e4ca3e40f..bb17cbed9a4 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -9,6 +9,7 @@ function createHarness(params?: { resetSession?: ReturnType; loadHistory?: LoadHistoryMock; setActivityStatus?: SetActivityStatusMock; + isConnected?: boolean; }) { const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" }); const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true }); @@ -27,6 +28,7 @@ function createHarness(params?: { state: { currentSessionKey: "agent:main:main", activeChatRunId: null, + isConnected: params?.isConnected ?? true, sessionInfo: {}, } as never, deliverDefault: false, @@ -126,4 +128,17 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down"); expect(setActivityStatus).toHaveBeenLastCalledWith("error"); }); + + it("reports disconnected status and skips gateway send when offline", async () => { + const { handleCommand, sendChat, addUser, addSystem, setActivityStatus } = createHarness({ + isConnected: false, + }); + + await handleCommand("/context"); + + expect(sendChat).not.toHaveBeenCalled(); + expect(addUser).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("not connected to gateway — message not sent"); + expect(setActivityStatus).toHaveBeenLastCalledWith("disconnected"); + }); }); From 31f2bf9519d15580e9252d9a872b70c4ee54dd31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:39:29 +0000 Subject: [PATCH 302/314] test: fix gate regressions --- scripts/test-parallel.mjs | 21 +++++++++- ...nk-low-reasoning-capable-models-no.test.ts | 41 +++++++++---------- ....agent-contract-snapshot-endpoints.test.ts | 18 ++++---- src/cli/update-cli.test.ts | 8 ---- src/config/io.write-config.test.ts | 2 +- src/process/exec.test.ts | 4 +- 6 files changed, 53 insertions(+), 41 deletions(-) diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index cb7e950a5da..0ec8d2fdc5f 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -19,6 +19,25 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/tool-meta.test.ts", "src/auto-reply/envelope.test.ts", "src/commands/auth-choice.test.ts", + // Process supervision + docker setup suites are stable but setup-heavy. + "src/process/supervisor/supervisor.test.ts", + "src/docker-setup.test.ts", + // Filesystem-heavy skills sync suite. + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", + // Real git hook integration test; keep signal, move off unit-fast critical path. + "test/git-hooks-pre-commit.test.ts", + // Setup-heavy doctor command suites; keep them off the unit-fast critical path. + "src/commands/doctor.warns-state-directory-is-missing.test.ts", + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", + // Setup-heavy CLI update flow suite; move off unit-fast critical path. + "src/cli/update-cli.test.ts", + // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. + "src/config/schema.test.ts", + "src/config/schema.tags.test.ts", + // CLI smoke/agent flows are stable but setup-heavy. + "src/cli/program.smoke.test.ts", + "src/commands/agent.test.ts", "src/media/store.test.ts", "src/media/store.header-ext.test.ts", "src/web/media.test.ts", @@ -210,7 +229,7 @@ const defaultWorkerBudget = unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: Math.max(2, Math.min(4, Math.floor(localWorkers / 3))), + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), } : lowMemLocalHost ? { diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 27e41f414ac..e3b6970a68e 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -112,7 +112,7 @@ async function runReasoningDefaultCase(params: { describe("directive behavior", () => { installDirectiveBehaviorE2EHooks(); - it("shows /think defaults for reasoning and non-reasoning models", async () => { + it("covers /think status and reasoning defaults for reasoning and non-reasoning models", async () => { await withTempHome(async (home) => { await expectThinkStatusForReasoningModel({ home, @@ -125,6 +125,25 @@ describe("directive behavior", () => { expectedLevel: "off", }); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + + vi.mocked(runEmbeddedPiAgent).mockClear(); + + for (const scenario of [ + { + expectedThinkLevel: "low" as const, + expectedReasoningLevel: "off" as const, + }, + { + expectedThinkLevel: "off" as const, + expectedReasoningLevel: "on" as const, + thinkingDefault: "off" as const, + }, + ]) { + await runReasoningDefaultCase({ + home, + ...scenario, + }); + } }); }); it("renders model list and status variants across catalog/config combinations", async () => { @@ -282,26 +301,6 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); }); }); - it("applies reasoning defaults based on thinkingDefault configuration", async () => { - await withTempHome(async (home) => { - for (const scenario of [ - { - expectedThinkLevel: "low" as const, - expectedReasoningLevel: "off" as const, - }, - { - expectedThinkLevel: "off" as const, - expectedReasoningLevel: "on" as const, - thinkingDefault: "off" as const, - }, - ]) { - await runReasoningDefaultCase({ - home, - ...scenario, - }); - } - }); - }); it("passes elevated defaults when sender is approved", async () => { await withTempHome(async (home) => { mockEmbeddedTextResult("done"); diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts index 8fddcccc0b8..7e300fe5aee 100644 --- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts @@ -64,14 +64,16 @@ describe("browser control server", () => { }); expect(nav.ok).toBe(true); expect(typeof nav.targetId).toBe("string"); - expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: state.cdpBaseUrl, - targetId: "abcd1234", - url: "https://example.com", - ssrfPolicy: { - dangerouslyAllowPrivateNetwork: true, - }, - }); + expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith( + expect.objectContaining({ + cdpUrl: state.cdpBaseUrl, + targetId: "abcd1234", + url: "https://example.com", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, + }, + }), + ); const click = await postJson<{ ok: boolean }>(`${base}/act`, { kind: "click", diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fe158fbb5f5..7edff76fe67 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -636,14 +636,6 @@ describe("update-cli", () => { } }); - it("updateCommand skips restart when --no-restart is set", async () => { - vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); - - await updateCommand({ restart: false }); - - expect(runDaemonRestart).not.toHaveBeenCalled(); - }); - it("updateCommand skips success message when restart does not run", async () => { vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult()); vi.mocked(runDaemonRestart).mockResolvedValue(false); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 19bb776b49a..18474914681 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -134,7 +134,7 @@ describe("config io write", () => { logger: silentLogger, }); - const invalidConfig = { + const invalidConfig: OpenClawConfig = { channels: { telegram: { dmPolicy: "open", diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 1f41edaa040..d5da9b0a0b7 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -105,12 +105,12 @@ describe("runCommandWithTimeout", () => { "clearInterval(ticker);", "process.exit(0);", "}", - "}, 40);", + "}, 12);", ].join(" "), ], { timeoutMs: 5_000, - noOutputTimeoutMs: 1_500, + noOutputTimeoutMs: 120, }, ); From ee423819517f06397cac1e5938003edf04c920af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:43:28 +0000 Subject: [PATCH 303/314] chore: add mailmap mappings for cherry-picked contributors --- .mailmap | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000000..9190f88b6e0 --- /dev/null +++ b/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> From 10cd4b5e6811f161e0a06762029b4a64356c8537 Mon Sep 17 00:00:00 2001 From: Arturo <34192856+afern247@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:44:11 +0000 Subject: [PATCH 304/314] chore: credit PR #24705 contributor attribution Attribution-only commit for the bot-authored upstream patch landed from #24705. From 91ea6ad8ec2e701d10c2c94684238ba2699a0769 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:46:19 +0000 Subject: [PATCH 305/314] docs(changelog): reorder unreleased fixes by user impact --- CHANGELOG.md | 77 ++++++++++++++++++++++++++-------------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d53e20462..376421b0833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,45 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. -- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. -- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. -- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. -- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. -- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. -- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. -- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. -- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. -- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. -- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) -- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) -- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) -- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) -- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) -- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) -- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) -- Slack/Restart sentinel: map `threadId` to `replyToId` for restart sentinel notifications. (#24885) -- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) -- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) -- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) -- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) -- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) -- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) -- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) -- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) -- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) -- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) -- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. -- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. -- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. -- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. -- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. -- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. -- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. - Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. @@ -65,8 +27,45 @@ Docs: https://docs.openclaw.ai - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. +- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. +- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. +- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. +- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) +- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. +- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. +- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. +- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. +- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. +- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. +- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) +- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. +- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) +- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) +- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. +- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) +- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) +- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) +- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. +- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. +- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. +- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) +- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) +- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) +- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) +- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) ## 2026.2.23 (Unreleased) From fd10286819b3826659ebc14dc5063295b8036090 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 04:47:36 +0000 Subject: [PATCH 306/314] docs(changelog): mark allowFrom id-only default as breaking --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 376421b0833..9d840c565ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Breaking - **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels..dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907) ### Changes From 936f2449bd26ce0465d70f2a262c3855d8b48c95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:02:40 +0000 Subject: [PATCH 307/314] chore(release): prep 2026.2.23-beta.1 changelog --- CHANGELOG.md | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d840c565ca..1362366f1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Changes - Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. ### Fixes @@ -51,7 +52,6 @@ Docs: https://docs.openclaw.ai - Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) - Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) - Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) -- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. - Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) - Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. - Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) @@ -77,6 +77,10 @@ Docs: https://docs.openclaw.ai - Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. - Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. - Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. ### Breaking @@ -88,8 +92,6 @@ Docs: https://docs.openclaw.ai - Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. - WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) - Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. -- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. -- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. - Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. @@ -101,8 +103,6 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. - Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. - Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. -- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. -- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. - Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. - Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. - Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. diff --git a/package.json b/package.json index be8ec9577e1..d1f6c8b5ec3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.23", + "version": "2026.2.23-beta.1", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From cafa8226d7c9cc1d451620196e0a414a29dd5cf5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:14:02 +0000 Subject: [PATCH 308/314] docs(changelog): move stop-signal expansion to changes --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1362366f1db..676279e0a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. ### Fixes @@ -36,7 +37,6 @@ Docs: https://docs.openclaw.ai - WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. - Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. - Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. - WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. - WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) - WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. From 8ea936cdda080b1c18fbc36b0ff549c174b87b20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:21:55 +0000 Subject: [PATCH 309/314] docs: clarify prompt caching intro --- docs/reference/prompt-caching.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 8233fd9de3a..67561e4a21b 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -9,6 +9,10 @@ read_when: # Prompt caching +Prompt caching means the model provider can reuse unchanged prompt prefixes (usually system/developer instructions and other stable context) across turns instead of re-processing them every time. The first matching request writes cache tokens (`cacheWrite`), and later matching requests can read them back (`cacheRead`). + +Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change. + This page covers all cache-related knobs that affect prompt reuse and token cost. For Anthropic pricing details, see: From b817600533129771ace2801d7c05901c7f850fb8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 05:36:38 +0000 Subject: [PATCH 310/314] chore(release): cut 2026.2.23 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676279e0a44..7c2f881c03c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ Docs: https://docs.openclaw.ai - Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) - Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) -## 2026.2.23 (Unreleased) +## 2026.2.23 ### Changes diff --git a/package.json b/package.json index d1f6c8b5ec3..be8ec9577e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.23-beta.1", + "version": "2026.2.23", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", From 4b316c33db6ca9868bc30fb632174a37ef500dc2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 24 Feb 2026 01:07:25 -0500 Subject: [PATCH 311/314] Auto-reply: normalize stop matching and add multilingual triggers (#25103) * Auto-reply tests: cover multilingual abort triggers * Auto-reply: normalize multilingual abort triggers * Gateway: route chat stop matching through abort parser * Gateway tests: cover chat stop parsing variants * Auto-reply tests: cover Russian and German stop words * Auto-reply: add Russian and German abort triggers * Gateway tests: include Russian and German stop forms * Telegram tests: route Russian and German stop forms to control lane * Changelog: note multilingual abort stop coverage * Changelog: add shared credit for abort shortcut update --- CHANGELOG.md | 2 +- src/auto-reply/reply/abort.test.ts | 25 ++++++++++++++++++++ src/auto-reply/reply/abort.ts | 21 ++++++++++++++++ src/gateway/chat-abort.test.ts | 17 +++++++++++++ src/gateway/chat-abort.ts | 8 ++----- src/telegram/bot.create-telegram-bot.test.ts | 10 ++++++++ 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2f881c03c..4807eba7c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Docs: https://docs.openclaw.ai - Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. -- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants) and accept trailing punctuation (for example `STOP OPENCLAW!!!`) so emergency stop messages are caught more reliably. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. Thanks @steipete and @vincentkoc. ### Fixes diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts index b36855eb80c..b35937a6003 100644 --- a/src/auto-reply/reply/abort.test.ts +++ b/src/auto-reply/reply/abort.test.ts @@ -147,6 +147,25 @@ describe("abort detection", () => { "STOP OPENCLAW", "stop openclaw!!!", "stop don’t do anything", + "detente", + "detén", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", ]; for (const candidate of positives) { expect(isAbortTrigger(candidate)).toBe(true); @@ -164,6 +183,12 @@ describe("abort detection", () => { expect(isAbortRequestText("stop")).toBe(true); expect(isAbortRequestText("stop action")).toBe(true); expect(isAbortRequestText("stop openclaw!!!")).toBe(true); + expect(isAbortRequestText("やめて")).toBe(true); + expect(isAbortRequestText("остановись")).toBe(true); + expect(isAbortRequestText("halt")).toBe(true); + expect(isAbortRequestText("stopp")).toBe(true); + expect(isAbortRequestText("pare")).toBe(true); + expect(isAbortRequestText(" توقف ")).toBe(true); expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true); expect(isAbortRequestText("/status")).toBe(false); diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 38bf576a435..1f3572464e8 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -30,6 +30,27 @@ const ABORT_TRIGGERS = new Set([ "wait", "exit", "interrupt", + "detente", + "deten", + "detén", + "arrete", + "arrête", + "停止", + "やめて", + "止めて", + "रुको", + "توقف", + "стоп", + "остановись", + "останови", + "остановить", + "прекрати", + "halt", + "anhalten", + "aufhören", + "hoer auf", + "stopp", + "pare", "stop openclaw", "openclaw stop", "stop action", diff --git a/src/gateway/chat-abort.test.ts b/src/gateway/chat-abort.test.ts index 9829f45c999..b008d7cc591 100644 --- a/src/gateway/chat-abort.test.ts +++ b/src/gateway/chat-abort.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { abortChatRunById, + isChatStopCommandText, type ChatAbortOps, type ChatAbortControllerEntry, } from "./chat-abort.js"; @@ -42,6 +43,22 @@ function createOps(params: { }; } +describe("isChatStopCommandText", () => { + it("matches slash and standalone multilingual stop forms", () => { + expect(isChatStopCommandText(" /STOP!!! ")).toBe(true); + expect(isChatStopCommandText("stop please")).toBe(true); + expect(isChatStopCommandText("停止")).toBe(true); + expect(isChatStopCommandText("やめて")).toBe(true); + expect(isChatStopCommandText("توقف")).toBe(true); + expect(isChatStopCommandText("остановись")).toBe(true); + expect(isChatStopCommandText("halt")).toBe(true); + expect(isChatStopCommandText("stopp")).toBe(true); + expect(isChatStopCommandText("pare")).toBe(true); + expect(isChatStopCommandText("/status")).toBe(false); + expect(isChatStopCommandText("keep going")).toBe(false); + }); +}); + describe("abortChatRunById", () => { it("broadcasts aborted payload with partial message when buffered text exists", () => { const runId = "run-1"; diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 0d544324133..0210f9223f7 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -1,4 +1,4 @@ -import { isAbortTrigger } from "../auto-reply/reply/abort.js"; +import { isAbortRequestText } from "../auto-reply/reply/abort.js"; export type ChatAbortControllerEntry = { controller: AbortController; @@ -9,11 +9,7 @@ export type ChatAbortControllerEntry = { }; export function isChatStopCommandText(text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) { - return false; - } - return trimmed.toLowerCase() === "/stop" || isAbortTrigger(trimmed); + return isAbortRequestText(text); } export function resolveChatRunExpiresAtMs(params: { diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index f5c4735ea75..816cf224dd3 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -184,6 +184,16 @@ describe("createTelegramBot", () => { message: mockMessage({ chat: mockChat({ id: 123 }), text: "stop please" }), }), ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "остановись" }), + }), + ).toBe("telegram:123:control"); + expect( + getTelegramSequentialKey({ + message: mockMessage({ chat: mockChat({ id: 123 }), text: "halt" }), + }), + ).toBe("telegram:123:control"); expect( getTelegramSequentialKey({ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/abort" }), From 1c228dc249c7034145029341f7f500c3be260401 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:50:30 -0600 Subject: [PATCH 312/314] docs: add Val Alexander to maintainers list (#25197) * docs: add Val Alexander to maintainers list - Focus: UI/UX, Docs, and Agent DevX - GitHub: @BunsDev - X/Twitter: @BunsDev * Update CONTRIBUTING.md * fix: format --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d4f290704..1386bc4881a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,9 @@ Welcome to the lobster tank! 🦞 - **Vincent Koc** - Agents, Telemetry, Hooks, Security - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) +- **Val Alexander** - UI/UX, Docs, and Agent DevX + - GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev) + - **Seb Slight** - Docs, Agent Reliability, Runtime Hardening - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) From 097a6a83a0184e0977ec6836177f930a01305337 Mon Sep 17 00:00:00 2001 From: Peter Machona Date: Tue, 24 Feb 2026 09:19:59 +0000 Subject: [PATCH 313/314] fix(cli): replace stale doctor/restart command hints (#24485) * fix(cli): replace stale doctor and restart hints * fix: add changelog for CLI hint updates (#24485) (thanks @chilu18) --------- Co-authored-by: Muhammed Mukhthar CM --- CHANGELOG.md | 1 + src/cli/daemon-cli/lifecycle.test.ts | 1 + src/cli/daemon-cli/lifecycle.ts | 2 +- src/cli/update-cli/update-command.ts | 2 +- src/commands/doctor-memory-search.test.ts | 21 ++++++++++++++++++--- src/commands/doctor-memory-search.ts | 4 ++-- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4807eba7c7a..4af2feb0b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. - Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) - Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. - Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) - Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) - Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 741473f69c4..41f7da868a3 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -126,6 +126,7 @@ describe("runDaemonRestart health checks", () => { await expect(runDaemonRestart({ json: true })).rejects.toMatchObject({ message: "Gateway restart timed out after 60s waiting for health checks.", + hints: ["openclaw gateway status --deep", "openclaw doctor"], }); expect(terminateStaleGatewayPids).not.toHaveBeenCalled(); expect(renderRestartDiagnostics).toHaveBeenCalledTimes(1); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 41332028945..f6d230f0bb8 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -135,7 +135,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } fail(`Gateway restart timed out after ${restartWaitSeconds}s waiting for health checks.`, [ - formatCliCommand("openclaw gateway status --probe --deep"), + formatCliCommand("openclaw gateway status --deep"), formatCliCommand("openclaw doctor"), ]); }, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 3c672a02d5e..1cce6c66e8e 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -589,7 +589,7 @@ async function maybeRestartService(params: { } defaultRuntime.log( theme.muted( - `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --probe --deep"), CLI_NAME)}\` for details.`, + `Run \`${replaceCliName(formatCliCommand("openclaw gateway status --deep"), CLI_NAME)}\` for details.`, ), ); } diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index a275fa60098..1c5c7a74d2d 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -143,7 +143,7 @@ describe("noteMemorySearchHealth", () => { expect(message).toContain("reports memory embeddings are ready"); }); - it("uses configure hint when gateway probe is unavailable and API key is missing", async () => { + it("uses model configure hint when gateway probe is unavailable and API key is missing", async () => { resolveMemorySearchConfig.mockReturnValue({ provider: "gemini", local: {}, @@ -160,8 +160,23 @@ describe("noteMemorySearchHealth", () => { const message = note.mock.calls[0]?.[0] as string; expect(message).toContain("Gateway memory probe for default agent is not ready"); - expect(message).toContain("openclaw configure"); - expect(message).not.toContain("auth add"); + expect(message).toContain("openclaw configure --section model"); + expect(message).not.toContain("openclaw auth add --provider"); + }); + + it("uses model configure hint in auto mode when no provider credentials are found", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg); + + expect(note).toHaveBeenCalledTimes(1); + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("openclaw configure --section model"); + expect(message).not.toContain("openclaw auth add --provider"); }); }); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 5b5d39dd56f..aebaef40229 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -84,7 +84,7 @@ export async function noteMemorySearchHealth( "", "Fix (pick one):", `- Set ${envVar} in your environment`, - `- Configure credentials: ${formatCliCommand("openclaw configure")}`, + `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, @@ -125,7 +125,7 @@ export async function noteMemorySearchHealth( "", "Fix (pick one):", "- Set OPENAI_API_KEY, GEMINI_API_KEY, VOYAGE_API_KEY, or MISTRAL_API_KEY in your environment", - `- Configure credentials: ${formatCliCommand("openclaw configure")}`, + `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, `- For local embeddings: configure agents.defaults.memorySearch.provider and local model path`, `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, "", From 66e61ca6ce4442d721f11dbd35d4f527ceb775f0 Mon Sep 17 00:00:00 2001 From: LawrenceLuo <2507073658@qq.com> Date: Tue, 24 Feb 2026 21:27:23 +0900 Subject: [PATCH 314/314] docs: fix broken links in README (#25368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /start/faq → /help/faq - /concepts/groups → /channels/groups - /concepts/group-messages → /channels/group-messages - /concepts/channel-routing → /channels/channel-routing Co-authored-by: LawrenceLuo <5390633+PinoHouse@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7387372192f..1dcad2b7e12 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. @@ -145,13 +145,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. - [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). - [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). - [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. -- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups). +- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups). - [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). ### Channels - [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). -- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). +- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). ### Apps + nodes @@ -170,7 +170,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### Runtime + safety -- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). +- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). - [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking). - [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning). - [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).