From f50fc2966b5c67053bcc9d95d1ec1ca0498499c2 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Tue, 10 Mar 2026 07:19:13 -0700 Subject: [PATCH 01/31] =?UTF-8?q?docs:=20add=20#42173=20to=20CHANGELOG=20?= =?UTF-8?q?=E2=80=94=20strip=20leaked=20model=20control=20tokens=20(#42216?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks @imwyvern. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dad04a01733..239646425ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern. - Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant. - Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura. - ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob. From ac88a39accdf915c412a0f6904425989a24bc885 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 20:29:03 +0530 Subject: [PATCH 02/31] fix: align pi-ai 0.57.1 oauth imports and payload hooks --- src/agents/anthropic-payload-log.test.ts | 2 +- src/agents/anthropic-payload-log.ts | 2 +- ...auth.openai-codex-refresh-fallback.test.ts | 18 ++++------ src/agents/auth-profiles/oauth.ts | 2 +- src/agents/openai-ws-stream.ts | 8 +++-- .../pi-embedded-runner-extraparams.test.ts | 34 +++++++++---------- .../anthropic-stream-wrappers.ts | 2 +- .../extra-params.kilocode.test.ts | 8 ++--- ...ra-params.openrouter-cache-control.test.ts | 2 +- src/agents/pi-embedded-runner/extra-params.ts | 6 ++-- .../extra-params.zai-tool-stream.test.ts | 2 +- .../moonshot-stream-wrappers.ts | 4 +-- .../openai-stream-wrappers.ts | 4 +-- .../proxy-stream-wrappers.ts | 6 ++-- .../pi-embedded-runner/run/attempt.test.ts | 2 +- src/agents/pi-embedded-runner/run/attempt.ts | 4 +-- src/commands/openai-codex-oauth.test.ts | 2 +- src/commands/openai-codex-oauth.ts | 2 +- src/tts/tts.test.ts | 3 ++ 19 files changed, 58 insertions(+), 55 deletions(-) diff --git a/src/agents/anthropic-payload-log.test.ts b/src/agents/anthropic-payload-log.test.ts index 037093fbbf5..fb3cf18e47d 100644 --- a/src/agents/anthropic-payload-log.test.ts +++ b/src/agents/anthropic-payload-log.test.ts @@ -29,7 +29,7 @@ describe("createAnthropicPayloadLogger", () => { ], }; const streamFn: StreamFn = ((model, __, options) => { - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); return {} as never; }) as StreamFn; diff --git a/src/agents/anthropic-payload-log.ts b/src/agents/anthropic-payload-log.ts index d80ed551179..2eb5d62e770 100644 --- a/src/agents/anthropic-payload-log.ts +++ b/src/agents/anthropic-payload-log.ts @@ -145,7 +145,7 @@ export function createAnthropicPayloadLogger(params: { payload: redactedPayload, payloadDigest: digest(redactedPayload), }); - return options?.onPayload?.(payload); + return options?.onPayload?.(payload, model); }; return streamFn(model, context, { ...options, diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 9d47be8c79e..23381d89a05 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -17,17 +17,13 @@ const { getOAuthApiKeyMock } = vi.hoisted(() => ({ }), })); -vi.mock("@mariozechner/pi-ai", async () => { - const actual = await vi.importActual("@mariozechner/pi-ai"); - return { - ...actual, - getOAuthApiKey: getOAuthApiKeyMock, - getOAuthProviders: () => [ - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret - ], - }; -}); +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: getOAuthApiKeyMock, + getOAuthProviders: () => [ + { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret + { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret + ], +})); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index a67e8e6a6bb..072b3a77246 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -1,5 +1,5 @@ import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; -import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai"; +import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth"; import { loadConfig, type OpenClawConfig } from "../../config/config.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { withFileLock } from "../../infra/file-lock.js"; diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index 9228fd92d46..dd82ced9e95 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -604,10 +604,14 @@ export function createOpenAIWebSocketStreamFn( ...(prevResponseId ? { previous_response_id: prevResponseId } : {}), ...extraParams, }; - options?.onPayload?.(payload); + const nextPayload = await options?.onPayload?.(payload, model); + const requestPayload = + nextPayload && typeof nextPayload === "object" + ? (nextPayload as Parameters[0]) + : (payload as Parameters[0]); try { - session.manager.send(payload as Parameters[0]); + session.manager.send(requestPayload); } catch (sendErr) { if (transport === "websocket") { throw sendErr instanceof Error ? sendErr : new Error(String(sendErr)); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 6689b3426cf..232cdfcaa0b 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -208,7 +208,7 @@ describe("applyExtraParamsToAgent", () => { }) { const payload = params.payload ?? { store: false }; const baseStreamFn: StreamFn = (model, _context, options) => { - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); return {} as ReturnType; }; const agent = { streamFn: baseStreamFn }; @@ -233,7 +233,7 @@ describe("applyExtraParamsToAgent", () => { }) { const payload = params.payload ?? {}; const baseStreamFn: StreamFn = (model, _context, options) => { - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); return {} as ReturnType; }; const agent = { streamFn: baseStreamFn }; @@ -276,7 +276,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { model: "deepseek/deepseek-r1" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -308,7 +308,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -332,7 +332,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -357,7 +357,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning: { max_tokens: 256 } }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -381,7 +381,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "medium" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -588,7 +588,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { thinking: "off" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -619,7 +619,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { thinking: "off" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -650,7 +650,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -674,7 +674,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { tool_choice: "required" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -699,7 +699,7 @@ describe("applyExtraParamsToAgent", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -749,7 +749,7 @@ describe("applyExtraParamsToAgent", () => { ], tool_choice: { type: "tool", name: "read" }, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -793,7 +793,7 @@ describe("applyExtraParamsToAgent", () => { }, ], }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -832,7 +832,7 @@ describe("applyExtraParamsToAgent", () => { }, ], }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -896,7 +896,7 @@ describe("applyExtraParamsToAgent", () => { }, }, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; @@ -943,7 +943,7 @@ describe("applyExtraParamsToAgent", () => { }, }, }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloads.push(payload); return {} as ReturnType; }; diff --git a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts index 66718b9e0aa..df43d2570c7 100644 --- a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts @@ -298,7 +298,7 @@ export function createAnthropicToolPayloadCompatibilityWrapper( ); } } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts index 509cdb5edf4..0e2fd5ce93b 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -19,7 +19,7 @@ function applyAndCapture(params: { const baseStreamFn: StreamFn = (_model, _context, options) => { captured.headers = options?.headers; - options?.onPayload?.({}); + options?.onPayload?.({}, _model); return createAssistantMessageEventStream(); }; const agent = { streamFn: baseStreamFn }; @@ -97,7 +97,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); capturedPayload = payload; return createAssistantMessageEventStream(); }; @@ -125,7 +125,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = {}; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); capturedPayload = payload; return createAssistantMessageEventStream(); }; @@ -158,7 +158,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { reasoning_effort: "high" }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); capturedPayload = payload; return createAssistantMessageEventStream(); }; diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts index 71af916ccac..58af2239a3d 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts @@ -13,7 +13,7 @@ type StreamPayload = { function runOpenRouterPayload(payload: StreamPayload, modelId: string) { const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); return createAssistantMessageEventStream(); }; const agent = { streamFn: baseStreamFn }; diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 6e261463d4a..8f36792f393 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -230,7 +230,7 @@ function createGoogleThinkingPayloadWrapper( thinkingLevel, }); } - return onPayload?.(payload); + return onPayload?.(payload, model); }, }); }; @@ -263,7 +263,7 @@ function createZaiToolStreamWrapper( // Inject tool_stream: true for Z.AI API (payload as Record).tool_stream = true; } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; @@ -310,7 +310,7 @@ function createParallelToolCallsWrapper( if (payload && typeof payload === "object") { (payload as Record).parallel_tool_calls = enabled; } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts index 2dab69cd15a..f7262a66798 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts @@ -22,7 +22,7 @@ type ToolStreamCase = { function runToolStreamCase(params: ToolStreamCase) { const payload: Record = { model: params.model.id, messages: [] }; const baseStreamFn: StreamFn = (model, _context, options) => { - options?.onPayload?.(payload); + options?.onPayload?.(payload, model); return {} as ReturnType; }; const agent = { streamFn: baseStreamFn }; diff --git a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts index aa43260e55e..282b0960a9d 100644 --- a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts @@ -60,7 +60,7 @@ export function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefi payloadObj.thinking = null; } } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; @@ -106,7 +106,7 @@ export function createMoonshotThinkingWrapper( payloadObj.tool_choice = "auto"; } } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 7c54a7dbc37..3fc46dac0ae 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -197,7 +197,7 @@ export function createOpenAIResponsesContextManagementWrapper( compactThreshold, }); } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; @@ -226,7 +226,7 @@ export function createOpenAIServiceTierWrapper( payloadObj.service_tier = serviceTier; } } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts index a5f9f5b1d85..4f77c31cfdd 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts @@ -92,7 +92,7 @@ export function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | unde } } } - return originalOnPayload?.(payload); + return originalOnPayload?.(payload, model); }, }); }; @@ -113,7 +113,7 @@ export function createOpenRouterWrapper( }, onPayload: (payload) => { normalizeProxyReasoningPayload(payload, thinkingLevel); - return onPayload?.(payload); + return onPayload?.(payload, model); }, }); }; @@ -138,7 +138,7 @@ export function createKilocodeWrapper( }, onPayload: (payload) => { normalizeProxyReasoningPayload(payload, thinkingLevel); - return onPayload?.(payload); + return onPayload?.(payload, model); }, }); }; diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 70bd3242f7c..9821adc0e0b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -520,7 +520,7 @@ describe("wrapOllamaCompatNumCtx", () => { let payloadSeen: Record | undefined; const baseFn = vi.fn((_model, _context, options) => { const payload: Record = { options: { temperature: 0.1 } }; - options?.onPayload?.(payload); + options?.onPayload?.(payload, _model); payloadSeen = payload; return {} as never; }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 80c674ae7c1..084a6d39746 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -230,14 +230,14 @@ export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: num ...options, onPayload: (payload: unknown) => { if (!payload || typeof payload !== "object") { - return options?.onPayload?.(payload); + return options?.onPayload?.(payload, model); } const payloadRecord = payload as Record; if (!payloadRecord.options || typeof payloadRecord.options !== "object") { payloadRecord.options = {}; } (payloadRecord.options as Record).num_ctx = numCtx; - return options?.onPayload?.(payload); + return options?.onPayload?.(payload, model); }, }); } diff --git a/src/commands/openai-codex-oauth.test.ts b/src/commands/openai-codex-oauth.test.ts index abe71d0bd42..43f1ac41f8a 100644 --- a/src/commands/openai-codex-oauth.test.ts +++ b/src/commands/openai-codex-oauth.test.ts @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ formatOpenAIOAuthTlsPreflightFix: vi.fn(), })); -vi.mock("@mariozechner/pi-ai", () => ({ +vi.mock("@mariozechner/pi-ai/oauth", () => ({ loginOpenAICodex: mocks.loginOpenAICodex, })); diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index 72a13f654cf..1f6a8f9cde8 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -1,5 +1,5 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; -import { loginOpenAICodex } from "@mariozechner/pi-ai"; +import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 733d34f5757..f3b5d8ce0ee 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -9,6 +9,9 @@ import * as tts from "./tts.js"; vi.mock("@mariozechner/pi-ai", () => ({ completeSimple: vi.fn(), +})); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ // Some auth helpers import oauth provider metadata at module load time. getOAuthProviders: () => [], getOAuthApiKey: vi.fn(async () => null), From 936607ca221a2f0c37ad976ddefcd39596f54793 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 20:35:23 +0530 Subject: [PATCH 03/31] ci: drop detect-secrets check --- .github/workflows/ci.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d248d5c804..2562d84d223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,34 +302,6 @@ jobs: python -m pip install --upgrade pip python -m pip install pre-commit - - name: Detect secrets - run: | - set -euo pipefail - - if [ "${{ github.event_name }}" = "push" ]; then - echo "Running full detect-secrets scan on push." - pre-commit run --all-files detect-secrets - exit 0 - fi - - BASE="${{ github.event.pull_request.base.sha }}" - changed_files=() - if git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then - while IFS= read -r path; do - [ -n "$path" ] || continue - [ -f "$path" ] || continue - changed_files+=("$path") - done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD) - fi - - if [ "${#changed_files[@]}" -gt 0 ]; then - echo "Running detect-secrets on ${#changed_files[@]} changed file(s)." - pre-commit run detect-secrets --files "${changed_files[@]}" - else - echo "Falling back to full detect-secrets scan." - pre-commit run --all-files detect-secrets - fi - - name: Detect committed private keys run: pre-commit run --all-files detect-private-key From bfeea5d23fc6516cdc376c6b5be442d39b0ae70b Mon Sep 17 00:00:00 2001 From: sline Date: Wed, 11 Mar 2026 00:52:49 +0800 Subject: [PATCH 04/31] fix(agents): prevent /v1beta duplication in Gemini PDF URL (#34369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip trailing /v1beta from baseUrl before appending the version segment, so callers that already include /v1beta in their base URL (e.g. subagent-registry) no longer produce /v1beta/v1beta/models/… which results in a 404 from the Gemini API. Closes #34312 Co-authored-by: Claude Opus 4.6 --- src/agents/tools/pdf-native-providers.ts | 7 +++---- src/agents/tools/pdf-tool.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/agents/tools/pdf-native-providers.ts b/src/agents/tools/pdf-native-providers.ts index 36d43ffb9f7..70a1e2e0e94 100644 --- a/src/agents/tools/pdf-native-providers.ts +++ b/src/agents/tools/pdf-native-providers.ts @@ -137,10 +137,9 @@ export async function geminiAnalyzePdf(params: { } parts.push({ text: params.prompt }); - const baseUrl = (params.baseUrl ?? "https://generativelanguage.googleapis.com").replace( - /\/+$/, - "", - ); + const baseUrl = (params.baseUrl ?? "https://generativelanguage.googleapis.com") + .replace(/\/+$/, "") + .replace(/\/v1beta$/, ""); const url = `${baseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent?key=${encodeURIComponent(apiKey)}`; const res = await fetch(url, { diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 6cbc6ca54d1..381fc53c4b9 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -711,6 +711,26 @@ describe("native PDF provider API calls", () => { "apiKey required", ); }); + + it("geminiAnalyzePdf does not duplicate /v1beta when baseUrl already includes it", async () => { + const { geminiAnalyzePdf } = await import("./pdf-native-providers.js"); + const fetchMock = mockFetchResponse({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: "ok" }] } }], + }), + }); + + await geminiAnalyzePdf( + makeGeminiAnalyzeParams({ + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + }), + ); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain("/v1beta/models/"); + expect(url).not.toContain("/v1beta/v1beta"); + }); }); // --------------------------------------------------------------------------- From 466cc816a828b684d43dbd4f9a11f6f12560e4fb Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 10 Mar 2026 10:06:09 -0700 Subject: [PATCH 05/31] docs(acp): document resumeSessionId for session resume (#42280) * docs(acp): document resumeSessionId for session resume * docs: clarify ACP resumeSessionId thread/mode behavior (#42280) (thanks @pejmanjohn) --------- Co-authored-by: Onur --- docs/tools/acp-agents.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index e41a96248ae..65a320f1c52 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -243,9 +243,36 @@ Interface details: - `mode: "session"` requires `thread: true` - `cwd` (optional): requested runtime working directory (validated by backend/runtime policy). - `label` (optional): operator-facing label used in session/banner text. +- `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`. - `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events. - When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`.acp-stream.jsonl`) you can tail for full relay history. +### Resume an existing session + +Use `resumeSessionId` to continue a previous ACP session instead of starting fresh. The agent replays its conversation history via `session/load`, so it picks up with full context of what came before. + +```json +{ + "task": "Continue where we left off — fix the remaining test failures", + "runtime": "acp", + "agentId": "codex", + "resumeSessionId": "" +} +``` + +Common use cases: + +- Hand off a Codex session from your laptop to your phone — tell your agent to pick up where you left off +- Continue a coding session you started interactively in the CLI, now headlessly through your agent +- Pick up work that was interrupted by a gateway restart or idle timeout + +Notes: + +- `resumeSessionId` requires `runtime: "acp"` — returns an error if used with the sub-agent runtime. +- `resumeSessionId` restores the upstream ACP conversation history; `thread` and `mode` still apply normally to the new OpenClaw session you are creating, so `mode: "session"` still requires `thread: true`. +- The target agent must support `session/load` (Codex and Claude Code do). +- If the session ID isn't found, the spawn fails with a clear error — no silent fallback to a new session. + ### Operator smoke test Use this after a gateway deploy when you want a quick live check that ACP spawn From 8bf64f219a5d48f9f34825c9cb95579400920585 Mon Sep 17 00:00:00 2001 From: CryUshio <1151291182@qq.com> Date: Wed, 11 Mar 2026 01:17:36 +0800 Subject: [PATCH 06/31] fix: recognize Poe 402 'used up your points' as billing for fallback (#42278) Merged via squash. Prepared head SHA: f3cdfa76dd9afcb023504eef723036e826e6ebc5 Co-authored-by: CryUshio <30655354+CryUshio@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../pi-embedded-helpers.isbillingerrormessage.test.ts | 6 ++++++ src/agents/pi-embedded-helpers/errors.ts | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 239646425ca..1712feda56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek. - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. +- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio. ## 2026.3.8 diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 3500df63876..afb60b81d09 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -646,6 +646,12 @@ describe("classifyFailoverReason", () => { expect(classifyFailoverReason("402 Payment Required: Weekly/Monthly Limit Exhausted")).toBe( "billing", ); + // Poe returns 402 without "payment required"; must be recognized for fallback + expect( + classifyFailoverReason( + "402 You've used up your points! Visit https://poe.com/api/keys to get more.", + ), + ).toBe("billing"); expect(classifyFailoverReason(INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 9ab52c04355..181ba89d8ce 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -237,7 +237,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ "exhausted", ] as const; const RAW_402_MARKER_RE = - /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b/i; + /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; From 3b582f1d54ea8949d8a0ecd0dc37642241e53216 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 10 Mar 2026 22:53:04 +0530 Subject: [PATCH 07/31] fix(telegram): chunk long html outbound messages (#42240) Merged via squash. Prepared head SHA: 4d79c41ddf33f44749355641936f8c425224ec6f Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/telegram/format.test.ts | 24 +++- src/telegram/format.ts | 211 ++++++++++++++++++++++++++++++++++++ src/telegram/send.test.ts | 139 ++++++++++++++++++++++++ src/telegram/send.ts | 192 ++++++++++++++++++++++++-------- 5 files changed, 519 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1712feda56f..b510bd37057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. - Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio. +- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. ## 2026.3.8 diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index ac4163b96f0..2fcd06663e0 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { markdownToTelegramHtml } from "./format.js"; +import { markdownToTelegramHtml, splitTelegramHtmlChunks } from "./format.js"; describe("markdownToTelegramHtml", () => { it("handles core markdown-to-telegram conversions", () => { @@ -112,4 +112,26 @@ describe("markdownToTelegramHtml", () => { expect(res).toContain("secret"); expect(res).toContain("trailing ||"); }); + + it("splits long multiline html text without breaking balanced tags", () => { + const chunks = splitTelegramHtmlChunks(`${"A\n".repeat(2500)}`, 4000); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); + expect(chunks[0]).toMatch(/^[\s\S]*<\/b>$/); + expect(chunks[1]).toMatch(/^[\s\S]*<\/b>$/); + }); + + it("fails loudly when a leading entity cannot fit inside a chunk", () => { + expect(() => splitTelegramHtmlChunks(`A&${"B".repeat(20)}`, 4)).toThrow(/leading entity/i); + }); + + it("treats malformed leading ampersands as plain text when chunking html", () => { + const chunks = splitTelegramHtmlChunks(`&${"A".repeat(5000)}`, 4000); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); + }); + + it("fails loudly when tag overhead leaves no room for text", () => { + expect(() => splitTelegramHtmlChunks("x", 10)).toThrow(/tag overhead/i); + }); }); diff --git a/src/telegram/format.ts b/src/telegram/format.ts index f74b508b42d..ed1f6c822f8 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -241,6 +241,217 @@ export function renderTelegramHtmlText( return markdownToTelegramHtml(text, { tableMode: options.tableMode }); } +type TelegramHtmlTag = { + name: string; + openTag: string; + closeTag: string; +}; + +const TELEGRAM_SELF_CLOSING_HTML_TAGS = new Set(["br"]); + +function buildTelegramHtmlOpenPrefix(tags: TelegramHtmlTag[]): string { + return tags.map((tag) => tag.openTag).join(""); +} + +function buildTelegramHtmlCloseSuffix(tags: TelegramHtmlTag[]): string { + return tags + .slice() + .toReversed() + .map((tag) => tag.closeTag) + .join(""); +} + +function buildTelegramHtmlCloseSuffixLength(tags: TelegramHtmlTag[]): number { + return tags.reduce((total, tag) => total + tag.closeTag.length, 0); +} + +function findTelegramHtmlEntityEnd(text: string, start: number): number { + if (text[start] !== "&") { + return -1; + } + let index = start + 1; + if (index >= text.length) { + return -1; + } + if (text[index] === "#") { + index += 1; + if (index >= text.length) { + return -1; + } + const isHex = text[index] === "x" || text[index] === "X"; + if (isHex) { + index += 1; + const hexStart = index; + while (/[0-9A-Fa-f]/.test(text[index] ?? "")) { + index += 1; + } + if (index === hexStart) { + return -1; + } + } else { + const digitStart = index; + while (/[0-9]/.test(text[index] ?? "")) { + index += 1; + } + if (index === digitStart) { + return -1; + } + } + } else { + const nameStart = index; + while (/[A-Za-z0-9]/.test(text[index] ?? "")) { + index += 1; + } + if (index === nameStart) { + return -1; + } + } + return text[index] === ";" ? index : -1; +} + +function findTelegramHtmlSafeSplitIndex(text: string, maxLength: number): number { + if (text.length <= maxLength) { + return text.length; + } + const normalizedMaxLength = Math.max(1, Math.floor(maxLength)); + const lastAmpersand = text.lastIndexOf("&", normalizedMaxLength - 1); + if (lastAmpersand === -1) { + return normalizedMaxLength; + } + const lastSemicolon = text.lastIndexOf(";", normalizedMaxLength - 1); + if (lastAmpersand < lastSemicolon) { + return normalizedMaxLength; + } + const entityEnd = findTelegramHtmlEntityEnd(text, lastAmpersand); + if (entityEnd === -1 || entityEnd < normalizedMaxLength) { + return normalizedMaxLength; + } + return lastAmpersand; +} + +function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void { + for (let index = tags.length - 1; index >= 0; index -= 1) { + if (tags[index]?.name === name) { + tags.splice(index, 1); + return; + } + } +} + +export function splitTelegramHtmlChunks(html: string, limit: number): string[] { + if (!html) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + if (html.length <= normalizedLimit) { + return [html]; + } + + const chunks: string[] = []; + const openTags: TelegramHtmlTag[] = []; + let current = ""; + let chunkHasPayload = false; + + const resetCurrent = () => { + current = buildTelegramHtmlOpenPrefix(openTags); + chunkHasPayload = false; + }; + + const flushCurrent = () => { + if (!chunkHasPayload) { + return; + } + chunks.push(`${current}${buildTelegramHtmlCloseSuffix(openTags)}`); + resetCurrent(); + }; + + const appendText = (segment: string) => { + let remaining = segment; + while (remaining.length > 0) { + const available = + normalizedLimit - current.length - buildTelegramHtmlCloseSuffixLength(openTags); + if (available <= 0) { + if (!chunkHasPayload) { + throw new Error( + `Telegram HTML chunk limit exceeded by tag overhead (limit=${normalizedLimit})`, + ); + } + flushCurrent(); + continue; + } + if (remaining.length <= available) { + current += remaining; + chunkHasPayload = true; + break; + } + const splitAt = findTelegramHtmlSafeSplitIndex(remaining, available); + if (splitAt <= 0) { + if (!chunkHasPayload) { + throw new Error( + `Telegram HTML chunk limit exceeded by leading entity (limit=${normalizedLimit})`, + ); + } + flushCurrent(); + continue; + } + current += remaining.slice(0, splitAt); + chunkHasPayload = true; + remaining = remaining.slice(splitAt); + flushCurrent(); + } + }; + + resetCurrent(); + HTML_TAG_PATTERN.lastIndex = 0; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = HTML_TAG_PATTERN.exec(html)) !== null) { + const tagStart = match.index; + const tagEnd = HTML_TAG_PATTERN.lastIndex; + appendText(html.slice(lastIndex, tagStart)); + + const rawTag = match[0]; + const isClosing = match[1] === "")); + + if (!isClosing) { + const nextCloseLength = isSelfClosing ? 0 : ``.length; + if ( + chunkHasPayload && + current.length + + rawTag.length + + buildTelegramHtmlCloseSuffixLength(openTags) + + nextCloseLength > + normalizedLimit + ) { + flushCurrent(); + } + } + + current += rawTag; + if (isSelfClosing) { + chunkHasPayload = true; + } + if (isClosing) { + popTelegramHtmlTag(openTags, tagName); + } else if (!isSelfClosing) { + openTags.push({ + name: tagName, + openTag: rawTag, + closeTag: ``, + }); + } + lastIndex = tagEnd; + } + + appendText(html.slice(lastIndex)); + flushCurrent(); + return chunks.length > 0 ? chunks : [html]; +} + function splitTelegramChunkByHtmlLimit( chunk: MarkdownIR, htmlLimit: number, diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index a34f27d196f..a00d1b2e89e 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1135,6 +1135,31 @@ describe("sendMessageTelegram", () => { }); }); + it("keeps disable_notification on plain-text fallback when silent is true", async () => { + const chatId = "123"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 2, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await sendMessageTelegram(chatId, "_oops_", { + token: "tok", + api, + silent: true, + }); + + expect(sendMessage.mock.calls).toEqual([ + [chatId, "oops", { parse_mode: "HTML", disable_notification: true }], + [chatId, "_oops_", { disable_notification: true }], + ]); + }); + it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => { const chatId = "-1001234567890"; const sendMessage = vi.fn().mockResolvedValue({ @@ -1257,6 +1282,120 @@ describe("sendMessageTelegram", () => { expect.objectContaining({ maxBytes: 42 * 1024 * 1024 }), ); }); + + it("chunks long html-mode text and keeps buttons on the last chunk only", async () => { + const chatId = "123"; + const htmlText = `${"A".repeat(5000)}`; + + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + buttons: [[{ text: "OK", callback_data: "ok" }]], + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + const firstCall = sendMessage.mock.calls[0]; + const secondCall = sendMessage.mock.calls[1]; + expect(firstCall).toBeDefined(); + expect(secondCall).toBeDefined(); + expect((firstCall[1] as string).length).toBeLessThanOrEqual(4000); + expect((secondCall[1] as string).length).toBeLessThanOrEqual(4000); + expect(firstCall[2]?.reply_markup).toBeUndefined(); + expect(secondCall[2]?.reply_markup).toEqual({ + inline_keyboard: [[{ text: "OK", callback_data: "ok" }]], + }); + expect(res.messageId).toBe("91"); + }); + + it("preserves caller plain-text fallback across chunked html parse retries", async () => { + const chatId = "123"; + const htmlText = `${"A".repeat(5000)}`; + const plainText = `${"P".repeat(2500)}${"Q".repeat(2500)}`; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } }) + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + plainText, + }); + + expect(sendMessage).toHaveBeenCalledTimes(4); + const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; + expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); + expect(plainFallbackCalls.every((call) => !String(call?.[1] ?? "").includes("<"))).toBe(true); + expect(res.messageId).toBe("91"); + }); + + it("keeps malformed leading ampersands on the chunked plain-text fallback path", async () => { + const chatId = "123"; + const htmlText = `&${"A".repeat(5000)}`; + const plainText = "fallback!!"; + const parseErr = new Error( + "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 0", + ); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 92, chat: { id: chatId } }) + .mockRejectedValueOnce(parseErr) + .mockResolvedValueOnce({ message_id: 93, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + plainText, + }); + + expect(sendMessage).toHaveBeenCalledTimes(4); + expect(String(sendMessage.mock.calls[0]?.[1] ?? "")).toMatch(/^&/); + const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; + expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); + expect(plainFallbackCalls.every((call) => String(call?.[1] ?? "").length > 0)).toBe(true); + expect(res.messageId).toBe("93"); + }); + + it("cuts over to plain text when fallback text needs more chunks than html", async () => { + const chatId = "123"; + const htmlText = `${"A".repeat(5000)}`; + const plainText = "P".repeat(9000); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 94, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 95, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 96, chat: { id: chatId } }); + const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; + + const res = await sendMessageTelegram(chatId, htmlText, { + token: "tok", + api, + textMode: "html", + plainText, + }); + + expect(sendMessage).toHaveBeenCalledTimes(3); + expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === undefined)).toBe(true); + expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toBe(plainText); + expect(res.messageId).toBe("96"); + }); }); describe("reactMessageTelegram", () => { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 313abf361e8..fa26df0209a 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -26,7 +26,7 @@ import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helper import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; -import { renderTelegramHtmlText } from "./format.js"; +import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js"; import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { recordSentMessage } from "./sent-message-cache.js"; @@ -108,6 +108,42 @@ function resolveTelegramMessageIdOrThrow( throw new Error(`Telegram ${context} returned no message_id`); } +function splitTelegramPlainTextChunks(text: string, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const chunks: string[] = []; + for (let start = 0; start < text.length; start += normalizedLimit) { + chunks.push(text.slice(start, start + normalizedLimit)); + } + return chunks; +} + +function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit); + if (chunkCount <= 1 || fixedChunks.length >= chunkCount) { + return fixedChunks; + } + const chunks: string[] = []; + let offset = 0; + for (let index = 0; index < chunkCount; index += 1) { + const remainingChars = text.length - offset; + const remainingChunks = chunkCount - index; + const nextChunkLength = + remainingChunks === 1 + ? remainingChars + : Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks)); + chunks.push(text.slice(offset, offset + nextChunkLength)); + offset += nextChunkLength; + } + return chunks; +} + const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i; const MESSAGE_NOT_MODIFIED_RE = @@ -596,27 +632,49 @@ export async function sendMessageTelegram( const linkPreviewEnabled = account.config.linkPreview ?? true; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; - const sendTelegramText = async ( - rawText: string, + type TelegramTextChunk = { + plainText: string; + htmlText?: string; + }; + + const sendTelegramTextChunk = async ( + chunk: TelegramTextChunk, params?: Record, - fallbackText?: string, ) => { return await withTelegramThreadFallback( params, "message", opts.verbose, async (effectiveParams, label) => { - const htmlText = renderHtmlText(rawText); const baseParams = effectiveParams ? { ...effectiveParams } : {}; if (linkPreviewOptions) { baseParams.link_preview_options = linkPreviewOptions; } - const hasBaseParams = Object.keys(baseParams).length > 0; - const sendParams = { - parse_mode: "HTML" as const, + const plainParams = { ...baseParams, ...(opts.silent === true ? { disable_notification: true } : {}), }; + const hasPlainParams = Object.keys(plainParams).length > 0; + const requestPlain = (retryLabel: string) => + requestWithChatNotFound( + () => + hasPlainParams + ? api.sendMessage( + chatId, + chunk.plainText, + plainParams as Parameters[2], + ) + : api.sendMessage(chatId, chunk.plainText), + retryLabel, + ); + if (!chunk.htmlText) { + return await requestPlain(label); + } + const htmlText = chunk.htmlText; + const htmlParams = { + parse_mode: "HTML" as const, + ...plainParams, + }; return await withTelegramHtmlParseFallback({ label, verbose: opts.verbose, @@ -626,27 +684,74 @@ export async function sendMessageTelegram( api.sendMessage( chatId, htmlText, - sendParams as Parameters[2], + htmlParams as Parameters[2], ), retryLabel, ), - requestPlain: (retryLabel) => { - const plainParams = hasBaseParams - ? (baseParams as Parameters[2]) - : undefined; - return requestWithChatNotFound( - () => - plainParams - ? api.sendMessage(chatId, fallbackText ?? rawText, plainParams) - : api.sendMessage(chatId, fallbackText ?? rawText), - retryLabel, - ); - }, + requestPlain, }); }, ); }; + const buildTextParams = (isLastChunk: boolean) => + hasThreadParams || (isLastChunk && replyMarkup) + ? { + ...threadParams, + ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined; + + const sendTelegramTextChunks = async ( + chunks: TelegramTextChunk[], + context: string, + ): Promise<{ messageId: string; chatId: string }> => { + let lastMessageId = ""; + let lastChatId = chatId; + for (let index = 0; index < chunks.length; index += 1) { + const chunk = chunks[index]; + if (!chunk) { + continue; + } + const res = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1)); + const messageId = resolveTelegramMessageIdOrThrow(res, context); + recordSentMessage(chatId, messageId); + lastMessageId = String(messageId); + lastChatId = String(res?.chat?.id ?? chatId); + } + return { messageId: lastMessageId, chatId: lastChatId }; + }; + + const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => { + const fallbackText = opts.plainText ?? rawText; + let htmlChunks: string[]; + try { + htmlChunks = splitTelegramHtmlChunks(rawText, 4000); + } catch (error) { + logVerbose( + `telegram ${context} failed HTML chunk planning, retrying as plain text: ${formatErrorMessage( + error, + )}`, + ); + return splitTelegramPlainTextChunks(fallbackText, 4000).map((plainText) => ({ plainText })); + } + const fixedPlainTextChunks = splitTelegramPlainTextChunks(fallbackText, 4000); + if (fixedPlainTextChunks.length > htmlChunks.length) { + logVerbose( + `telegram ${context} plain-text fallback needs more chunks than HTML; sending plain text`, + ); + return fixedPlainTextChunks.map((plainText) => ({ plainText })); + } + const plainTextChunks = splitTelegramPlainTextFallback(fallbackText, htmlChunks.length, 4000); + return htmlChunks.map((htmlText, index) => ({ + htmlText, + plainText: plainTextChunks[index] ?? htmlText, + })); + }; + + const sendChunkedText = async (rawText: string, context: string) => + await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context); + if (mediaUrl) { const media = await loadWebMedia( mediaUrl, @@ -801,21 +906,15 @@ export async function sendMessageTelegram( // If text was too long for a caption, send it as a separate follow-up message. // Use HTML conversion so markdown renders like captions. if (needsSeparateText && followUpText) { - const textParams = - hasThreadParams || replyMarkup - ? { - ...threadParams, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - } - : undefined; - const textRes = await sendTelegramText(followUpText, textParams); - // Return the text message ID as the "main" message (it's the actual content). - const textMessageId = resolveTelegramMessageIdOrThrow(textRes, "text follow-up send"); - recordSentMessage(chatId, textMessageId); - return { - messageId: String(textMessageId), - chatId: resolvedChatId, - }; + if (textMode === "html") { + const textResult = await sendChunkedText(followUpText, "text follow-up send"); + return { messageId: textResult.messageId, chatId: resolvedChatId }; + } + const textResult = await sendTelegramTextChunks( + [{ plainText: followUpText, htmlText: renderHtmlText(followUpText) }], + "text follow-up send", + ); + return { messageId: textResult.messageId, chatId: resolvedChatId }; } return { messageId: String(mediaMessageId), chatId: resolvedChatId }; @@ -824,22 +923,21 @@ export async function sendMessageTelegram( if (!text || !text.trim()) { throw new Error("Message must be non-empty for Telegram sends"); } - const textParams = - hasThreadParams || replyMarkup - ? { - ...threadParams, - ...(replyMarkup ? { reply_markup: replyMarkup } : {}), - } - : undefined; - const res = await sendTelegramText(text, textParams, opts.plainText); - const messageId = resolveTelegramMessageIdOrThrow(res, "text send"); - recordSentMessage(chatId, messageId); + let textResult: { messageId: string; chatId: string }; + if (textMode === "html") { + textResult = await sendChunkedText(text, "text send"); + } else { + textResult = await sendTelegramTextChunks( + [{ plainText: opts.plainText ?? text, htmlText: renderHtmlText(text) }], + "text send", + ); + } recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); - return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) }; + return textResult; } export async function sendTypingTelegram( From bc9b35d6ceb84ab223c0c2b20726c7fd5e3d9c71 Mon Sep 17 00:00:00 2001 From: jiarung Date: Wed, 11 Mar 2026 01:32:14 +0800 Subject: [PATCH 08/31] fix(logging): include model and provider in overload/error log (#41236) Merged via squash. Prepared head SHA: bb16fecbf7173dbd8f49adacb6147635bad00c56 Co-authored-by: jiarung <16461359+jiarung@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + ...edded-subscribe.handlers.lifecycle.test.ts | 28 ++++++++++++++++++- ...i-embedded-subscribe.handlers.lifecycle.ts | 6 ++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b510bd37057..2dd42d4601d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc. - Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio. - Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. +- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. ## 2026.3.8 diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index b93cf43cebe..911b124113a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -70,7 +70,7 @@ describe("handleAgentEnd", () => { }); }); - it("attaches raw provider error metadata without changing the console message", () => { + it("attaches raw provider error metadata and includes model/provider in console output", () => { const ctx = createContext({ role: "assistant", stopReason: "error", @@ -91,9 +91,35 @@ describe("handleAgentEnd", () => { error: "The AI service is temporarily overloaded. Please try again in a moment.", failoverReason: "overloaded", providerErrorType: "overloaded_error", + consoleMessage: + "embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment.", }); }); + it("sanitizes model and provider before writing consoleMessage", () => { + const ctx = createContext({ + role: "assistant", + stopReason: "error", + provider: "anthropic\u001b]8;;https://evil.test\u0007", + model: "claude\tsonnet\n4", + errorMessage: "connection refused", + content: [{ type: "text", text: "" }], + }); + + handleAgentEnd(ctx); + + const warn = vi.mocked(ctx.log.warn); + const meta = warn.mock.calls[0]?.[1]; + expect(meta).toMatchObject({ + consoleMessage: + "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=connection refused", + }); + expect(meta?.consoleMessage).not.toContain("\n"); + expect(meta?.consoleMessage).not.toContain("\r"); + expect(meta?.consoleMessage).not.toContain("\t"); + expect(meta?.consoleMessage).not.toContain("\u001b"); + }); + it("redacts logged error text before emitting lifecycle events", () => { const onAgentEvent = vi.fn(); const ctx = createContext( diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index c666784ff8e..973de1ebefc 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -48,6 +48,8 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { const safeErrorText = buildTextObservationFields(errorText).textPreview ?? "LLM request failed."; const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; + const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown"; + const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown"; ctx.log.warn("embedded run agent end", { event: "embedded_run_agent_end", tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], @@ -55,10 +57,10 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { isError: true, error: safeErrorText, failoverReason, - provider: lastAssistant.provider, model: lastAssistant.model, + provider: lastAssistant.provider, ...observedError, - consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true error=${safeErrorText}`, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}`, }); emitAgentEvent({ runId: ctx.params.runId, From e9e8b819399f05bb8e56359d8389bd8cf1737023 Mon Sep 17 00:00:00 2001 From: JiangNan <1394485448@qq.com> Date: Wed, 11 Mar 2026 01:34:32 +0800 Subject: [PATCH 09/31] fix(failover): classify Gemini MALFORMED_RESPONSE as retryable timeout (#42292) Merged via squash. Prepared head SHA: 68f106ff49fc7a28a806601bc8ca1e5e77c6e8c6 Co-authored-by: jnMetaCode <12096460+jnMetaCode@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + ...dded-helpers.isbillingerrormessage.test.ts | 20 +++++++++++++++++++ .../pi-embedded-helpers/failover-matches.ts | 6 +++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd42d4601d..1eb5fd711a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio. - Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. - Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. +- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. ## 2026.3.8 diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index afb60b81d09..608483b99bf 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -501,6 +501,26 @@ describe("isFailoverErrorMessage", () => { expect(isFailoverErrorMessage(sample)).toBe(true); } }); + + it("matches Gemini MALFORMED_RESPONSE stop reason as timeout (#42149)", () => { + const samples = [ + "Unhandled stop reason: MALFORMED_RESPONSE", + "Unhandled stop reason: malformed_response", + "stop reason: MALFORMED_RESPONSE", + ]; + for (const sample of samples) { + expect(isTimeoutErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("timeout"); + expect(isFailoverErrorMessage(sample)).toBe(true); + } + }); + + it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => { + const sample = "Unhandled stop reason: MALFORMED_FUNCTION_CALL"; + expect(isTimeoutErrorMessage(sample)).toBe(false); + expect(classifyFailoverReason(sample)).toBe(null); + expect(isFailoverErrorMessage(sample)).toBe(false); + }); }); describe("parseImageSizeError", () => { diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index f2e0e3870ab..a7948703f39 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -40,9 +40,9 @@ const ERROR_PATTERNS = { /\benotfound\b/i, /\beai_again\b/i, /without sending (?:any )?chunks?/i, - /\bstop reason:\s*(?:abort|error)\b/i, - /\breason:\s*(?:abort|error)\b/i, - /\bunhandled stop reason:\s*(?:abort|error)\b/i, + /\bstop reason:\s*(?:abort|error|malformed_response)\b/i, + /\breason:\s*(?:abort|error|malformed_response)\b/i, + /\bunhandled stop reason:\s*(?:abort|error|malformed_response)\b/i, ], billing: [ /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment/i, From c2d9386796635970b373ef83528625528f49cb2f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:38:49 +0800 Subject: [PATCH 10/31] fix: log auth profile resolution failures instead of swallowing silently (#41271) Merged via squash. Prepared head SHA: 049d1e119a4df88ae00870353a9e7134261fe9dd Co-authored-by: he-yufeng <40085740+he-yufeng@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/model-auth.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb5fd711a5..d01a8e11b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,7 @@ Docs: https://docs.openclaw.ai - Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution. - Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey. - Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau. +- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng. ## 2026.3.7 diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 51ba332ed7f..aa94fddb02e 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeOptionalSecretInput, normalizeSecretInput, @@ -22,6 +23,8 @@ import { normalizeProviderId } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; +const log = createSubsystemLogger("model-auth"); + const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK"; const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID"; const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY"; @@ -221,7 +224,9 @@ export async function resolveApiKeyForProvider(params: { mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key", }; } - } catch {} + } catch (err) { + log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); + } } const envResolved = resolveEnvApiKey(provider); From 0687e04760218fa4d6bf06c35b60786a1834d374 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:30:57 -0500 Subject: [PATCH 11/31] fix: thread runtime config through Discord/Telegram sends (#42352) (thanks @joshavant) (#42352) --- CHANGELOG.md | 1 + docs/channels/discord.md | 1 + docs/channels/telegram.md | 1 + docs/gateway/configuration-reference.md | 1 + docs/gateway/secrets.md | 2 + src/agents/tools/telegram-actions.test.ts | 95 +++++++++++++++++++ src/agents/tools/telegram-actions.ts | 7 ++ src/discord/client.test.ts | 91 ++++++++++++++++++ src/discord/client.ts | 60 ++++++++---- src/discord/monitor/agent-components.ts | 1 + .../monitor/message-handler.process.ts | 1 + src/discord/monitor/provider.ts | 1 + src/discord/monitor/reply-delivery.test.ts | 37 ++++++++ src/discord/monitor/reply-delivery.ts | 25 ++++- .../thread-bindings.discord-api.test.ts | 78 ++++++++++++++- .../monitor/thread-bindings.discord-api.ts | 30 ++++-- .../monitor/thread-bindings.lifecycle.test.ts | 79 ++++++++++++++- .../monitor/thread-bindings.lifecycle.ts | 3 + .../monitor/thread-bindings.manager.ts | 31 ++++-- .../outbound/cfg-threading.guard.test.ts | 17 ++++ src/telegram/send.ts | 15 ++- 21 files changed, 531 insertions(+), 46 deletions(-) create mode 100644 src/discord/client.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d01a8e11b2a..f571691a7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai - Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus. - Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. - Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. +- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. ## 2026.3.8 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 994c03391ce..48a8a03f59e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -168,6 +168,7 @@ openclaw pairing approve discord Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. +For advanced outbound calls (message tool/channel actions), an explicit per-call `token` is used for that call. Account policy/retry settings still come from the selected account in the active runtime snapshot. ## Recommended: Set up a guild workspace diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index a039cb43483..b29ec3c59d5 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -410,6 +410,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.actions.sticker` (default: disabled) Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles. + Runtime sends use the active config/secrets snapshot (startup/reload), so action paths do not perform ad-hoc SecretRef re-resolution per send. Reaction removal semantics: [/tools/reactions](/tools/reactions) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 538b80f6138..9a77f6ac1a3 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -304,6 +304,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ``` - Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account. +- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot. - Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id. - Use `user:` (DM) or `channel:` (guild channel) for delivery targets; bare numeric IDs are rejected. - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index e9d75343147..213c98f9f14 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -21,6 +21,7 @@ Secrets are resolved into an in-memory runtime snapshot. - Startup fails fast when an effectively active SecretRef cannot be resolved. - Reload uses atomic swap: full success, or keep the last-known-good snapshot. - Runtime requests read from the active in-memory snapshot only. +- Outbound delivery paths also read from that active snapshot (for example Discord reply/thread delivery and Telegram action sends); they do not re-resolve SecretRefs on each send. This keeps secret-provider outages off hot request paths. @@ -321,6 +322,7 @@ Activation contract: - Success swaps the snapshot atomically. - Startup failure aborts gateway startup. - Runtime reload failure keeps the last-known-good snapshot. +- Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`. ## Degraded and recovered signals diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index eeeb7bbf35b..e15b4bd2e17 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -18,6 +18,16 @@ const sendStickerTelegram = vi.fn(async () => ({ chatId: "123", })); const deleteMessageTelegram = vi.fn(async () => ({ ok: true })); +const editMessageTelegram = vi.fn(async () => ({ + ok: true, + messageId: "456", + chatId: "123", +})); +const createForumTopicTelegram = vi.fn(async () => ({ + topicId: 99, + name: "Topic", + chatId: "123", +})); let envSnapshot: ReturnType; vi.mock("../../telegram/send.js", () => ({ @@ -30,6 +40,10 @@ vi.mock("../../telegram/send.js", () => ({ sendStickerTelegram(...args), deleteMessageTelegram: (...args: Parameters) => deleteMessageTelegram(...args), + editMessageTelegram: (...args: Parameters) => + editMessageTelegram(...args), + createForumTopicTelegram: (...args: Parameters) => + createForumTopicTelegram(...args), })); describe("handleTelegramAction", () => { @@ -90,6 +104,8 @@ describe("handleTelegramAction", () => { sendPollTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); + editMessageTelegram.mockClear(); + createForumTopicTelegram.mockClear(); process.env.TELEGRAM_BOT_TOKEN = "tok"; }); @@ -379,6 +395,85 @@ describe("handleTelegramAction", () => { ); }); + it.each([ + { + name: "react", + params: { action: "react", chatId: "123", messageId: 456, emoji: "✅" }, + cfg: reactionConfig("minimal"), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(reactMessageTelegram.mock.calls as unknown[][], 3), + }, + { + name: "sendMessage", + params: { action: "sendMessage", to: "123", content: "hello" }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendMessageTelegram.mock.calls as unknown[][], 2), + }, + { + name: "poll", + params: { + action: "poll", + to: "123", + question: "Q?", + answers: ["A", "B"], + }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendPollTelegram.mock.calls as unknown[][], 2), + }, + { + name: "deleteMessage", + params: { action: "deleteMessage", chatId: "123", messageId: 1 }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(deleteMessageTelegram.mock.calls as unknown[][], 2), + }, + { + name: "editMessage", + params: { action: "editMessage", chatId: "123", messageId: 1, content: "updated" }, + cfg: telegramConfig(), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(editMessageTelegram.mock.calls as unknown[][], 3), + }, + { + name: "sendSticker", + params: { action: "sendSticker", to: "123", fileId: "sticker-1" }, + cfg: telegramConfig({ actions: { sticker: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(sendStickerTelegram.mock.calls as unknown[][], 2), + }, + { + name: "createForumTopic", + params: { action: "createForumTopic", chatId: "123", name: "Topic" }, + cfg: telegramConfig({ actions: { createForumTopic: true } }), + assertCall: ( + readCallOpts: (calls: unknown[][], argIndex: number) => Record, + ) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2), + }, + ])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => { + const readCallOpts = (calls: unknown[][], argIndex: number): Record => { + const args = calls[0]; + if (!Array.isArray(args)) { + throw new Error("Expected Telegram action call args"); + } + const opts = args[argIndex]; + if (!opts || typeof opts !== "object") { + throw new Error("Expected Telegram action options object"); + } + return opts as Record; + }; + await handleTelegramAction(params as Record, cfg); + const opts = assertCall(readCallOpts); + expect(opts.cfg).toBe(cfg); + }); + it.each([ { name: "media", diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 30c07530159..143d154e633 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -154,6 +154,7 @@ export async function handleTelegramAction( let reactionResult: Awaited>; try { reactionResult = await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { + cfg, token, remove, accountId: accountId ?? undefined, @@ -237,6 +238,7 @@ export async function handleTelegramAction( ); } const result = await sendMessageTelegram(to, content, { + cfg, token, accountId: accountId ?? undefined, mediaUrl: mediaUrl || undefined, @@ -293,6 +295,7 @@ export async function handleTelegramAction( durationHours: durationHours ?? undefined, }, { + cfg, token, accountId: accountId ?? undefined, replyToMessageId: replyToMessageId ?? undefined, @@ -327,6 +330,7 @@ export async function handleTelegramAction( ); } await deleteMessageTelegram(chatId ?? "", messageId ?? 0, { + cfg, token, accountId: accountId ?? undefined, }); @@ -367,6 +371,7 @@ export async function handleTelegramAction( ); } const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { + cfg, token, accountId: accountId ?? undefined, buttons, @@ -399,6 +404,7 @@ export async function handleTelegramAction( ); } const result = await sendStickerTelegram(to, fileId, { + cfg, token, accountId: accountId ?? undefined, replyToMessageId: replyToMessageId ?? undefined, @@ -454,6 +460,7 @@ export async function handleTelegramAction( ); } const result = await createForumTopicTelegram(chatId ?? "", name, { + cfg, token, accountId: accountId ?? undefined, iconColor: iconColor ?? undefined, diff --git a/src/discord/client.test.ts b/src/discord/client.test.ts new file mode 100644 index 00000000000..3dc156670e7 --- /dev/null +++ b/src/discord/client.test.ts @@ -0,0 +1,91 @@ +import type { RequestClient } from "@buape/carbon"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createDiscordRestClient } from "./client.js"; + +describe("createDiscordRestClient", () => { + const fakeRest = {} as RequestClient; + + it("uses explicit token without resolving config token SecretRefs", () => { + const cfg = { + channels: { + discord: { + token: { + source: "exec", + provider: "vault", + id: "discord/bot-token", + }, + }, + }, + } as OpenClawConfig; + + const result = createDiscordRestClient( + { + token: "Bot explicit-token", + rest: fakeRest, + }, + cfg, + ); + + expect(result.token).toBe("explicit-token"); + expect(result.rest).toBe(fakeRest); + expect(result.account.accountId).toBe("default"); + }); + + it("keeps account retry config when explicit token is provided", () => { + const cfg = { + channels: { + discord: { + accounts: { + ops: { + token: { + source: "exec", + provider: "vault", + id: "discord/ops-token", + }, + retry: { + attempts: 7, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + const result = createDiscordRestClient( + { + accountId: "ops", + token: "Bot explicit-account-token", + rest: fakeRest, + }, + cfg, + ); + + expect(result.token).toBe("explicit-account-token"); + expect(result.account.accountId).toBe("ops"); + expect(result.account.config.retry).toMatchObject({ attempts: 7 }); + }); + + it("still throws when no explicit token is provided and config token is unresolved", () => { + const cfg = { + channels: { + discord: { + token: { + source: "file", + provider: "default", + id: "/discord/token", + }, + }, + }, + } as OpenClawConfig; + + expect(() => + createDiscordRestClient( + { + rest: fakeRest, + }, + cfg, + ), + ).toThrow(/unresolved SecretRef/i); + }); +}); diff --git a/src/discord/client.ts b/src/discord/client.ts index 4f754fa8624..62d917cebb6 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -2,10 +2,16 @@ import { RequestClient } from "@buape/carbon"; import { loadConfig } from "../config/config.js"; import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-policy.js"; import type { RetryConfig } from "../infra/retry.js"; -import { resolveDiscordAccount } from "./accounts.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import { + mergeDiscordAccountConfig, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; import { normalizeDiscordToken } from "./token.js"; export type DiscordClientOpts = { + cfg?: ReturnType; token?: string; accountId?: string; rest?: RequestClient; @@ -13,11 +19,7 @@ export type DiscordClientOpts = { verbose?: boolean; }; -function resolveToken(params: { explicit?: string; accountId: string; fallbackToken?: string }) { - const explicit = normalizeDiscordToken(params.explicit, "channels.discord.token"); - if (explicit) { - return explicit; - } +function resolveToken(params: { accountId: string; fallbackToken?: string }) { const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token"); if (!fallback) { throw new Error( @@ -31,22 +33,48 @@ function resolveRest(token: string, rest?: RequestClient) { return rest ?? new RequestClient(token); } -export function createDiscordRestClient(opts: DiscordClientOpts, cfg = loadConfig()) { - const account = resolveDiscordAccount({ cfg, accountId: opts.accountId }); - const token = resolveToken({ - explicit: opts.token, - accountId: account.accountId, - fallbackToken: account.token, - }); +function resolveAccountWithoutToken(params: { + cfg: ReturnType; + accountId?: string; +}): ResolvedDiscordAccount { + const accountId = normalizeAccountId(params.accountId); + const merged = mergeDiscordAccountConfig(params.cfg, accountId); + const baseEnabled = params.cfg.channels?.discord?.enabled !== false; + const accountEnabled = merged.enabled !== false; + return { + accountId, + enabled: baseEnabled && accountEnabled, + name: merged.name?.trim() || undefined, + token: "", + tokenSource: "none", + config: merged, + }; +} + +export function createDiscordRestClient( + opts: DiscordClientOpts, + cfg?: ReturnType, +) { + const resolvedCfg = opts.cfg ?? cfg ?? loadConfig(); + const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token"); + const account = explicitToken + ? resolveAccountWithoutToken({ cfg: resolvedCfg, accountId: opts.accountId }) + : resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId }); + const token = + explicitToken ?? + resolveToken({ + accountId: account.accountId, + fallbackToken: account.token, + }); const rest = resolveRest(token, opts.rest); return { token, rest, account }; } export function createDiscordClient( opts: DiscordClientOpts, - cfg = loadConfig(), + cfg?: ReturnType, ): { token: string; rest: RequestClient; request: RetryRunner } { - const { token, rest, account } = createDiscordRestClient(opts, cfg); + const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg); const request = createDiscordRetryRunner({ retry: opts.retry, configRetry: account.config.retry, @@ -56,5 +84,5 @@ export function createDiscordClient( } export function resolveDiscordRest(opts: DiscordClientOpts) { - return createDiscordRestClient(opts).rest; + return createDiscordRestClient(opts, opts.cfg).rest; } diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index 16b3f564bfe..56e7dfe3240 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -1009,6 +1009,7 @@ async function dispatchDiscordComponentEvent(params: { deliver: async (payload) => { const replyToId = replyReference.use(); await deliverDiscordReply({ + cfg: ctx.cfg, replies: [payload], target: deliverTarget, token, diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index c283658ac09..ea64b37f98e 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -684,6 +684,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const replyToId = replyReference.use(); await deliverDiscordReply({ + cfg, replies: [payload], target: deliverTarget, token, diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index b0825d03345..08de298a062 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -441,6 +441,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ? createThreadBindingManager({ accountId: account.accountId, token, + cfg, idleTimeoutMs: threadBindingIdleTimeoutMs, maxAgeMs: threadBindingMaxAgeMs, }) diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 3d0357ef43a..1e0bdc00942 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { @@ -23,6 +24,9 @@ vi.mock("../send.shared.js", () => ({ describe("deliverDiscordReply", () => { const runtime = {} as RuntimeEnv; + const cfg = { + channels: { discord: { token: "test-token" } }, + } as OpenClawConfig; const createBoundThreadBindings = async ( overrides: Partial<{ threadId: string; @@ -86,6 +90,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", }); @@ -128,6 +133,7 @@ describe("deliverDiscordReply", () => { target: "channel:456", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -147,6 +153,7 @@ describe("deliverDiscordReply", () => { target: "channel:654", token: "token", runtime, + cfg, textLimit: 2000, mediaLocalRoots, }); @@ -174,6 +181,19 @@ describe("deliverDiscordReply", () => { ); }); + it("forwards cfg to Discord send helpers", async () => { + await deliverDiscordReply({ + replies: [{ text: "cfg path" }], + target: "channel:101", + token: "token", + runtime, + cfg, + textLimit: 2000, + }); + + expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.cfg).toBe(cfg); + }); + it("uses replyToId only for the first chunk when replyToMode is first", async () => { await deliverDiscordReply({ replies: [ @@ -184,6 +204,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 5, replyToId: "reply-1", replyToMode: "first", @@ -200,6 +221,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", replyToMode: "first", @@ -219,6 +241,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -246,6 +269,7 @@ describe("deliverDiscordReply", () => { token: "token", rest: fakeRest, runtime, + cfg, textLimit: 5, }); @@ -265,6 +289,7 @@ describe("deliverDiscordReply", () => { token: "token", rest: fakeRest, runtime, + cfg, textLimit: 2000, maxLinesPerMessage: 120, chunkMode: "newline", @@ -285,6 +310,7 @@ describe("deliverDiscordReply", () => { target: "channel:789", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -303,6 +329,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -320,6 +347,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }); @@ -336,6 +364,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }), ).rejects.toThrow("bad request"); @@ -353,6 +382,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2000, }), ).rejects.toThrow("rate limited"); @@ -372,6 +402,7 @@ describe("deliverDiscordReply", () => { target: "channel:123", token: "token", runtime, + cfg, textLimit: 2, }); @@ -386,6 +417,7 @@ describe("deliverDiscordReply", () => { target: "channel:thread-1", token: "token", runtime, + cfg, textLimit: 2000, replyToId: "reply-1", sessionKey: "agent:main:subagent:child", @@ -396,6 +428,7 @@ describe("deliverDiscordReply", () => { expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith( "Hello from subagent", expect.objectContaining({ + cfg, webhookId: "wh_1", webhookToken: "tok_1", accountId: "default", @@ -418,6 +451,7 @@ describe("deliverDiscordReply", () => { target: "channel:thread-1", token: "token", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, @@ -441,12 +475,14 @@ describe("deliverDiscordReply", () => { token: "token", accountId: "default", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, }); expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1); + expect(sendWebhookMessageDiscordMock.mock.calls[0]?.[1]?.cfg).toBe(cfg); expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1); expect(sendMessageDiscordMock).toHaveBeenCalledWith( "channel:thread-1", @@ -464,6 +500,7 @@ describe("deliverDiscordReply", () => { token: "token", accountId: "default", runtime, + cfg, textLimit: 2000, sessionKey: "agent:main:subagent:child", threadBindings, diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index d3e7ef9bf61..fb235ca65d0 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -2,7 +2,7 @@ import type { RequestClient } from "@buape/carbon"; import { resolveAgentAvatar } from "../../agents/identity-avatar.js"; import type { ChunkMode } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { loadConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js"; import { createDiscordRetryRunner, type RetryRunner } from "../../infra/retry-policy.js"; import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../infra/retry.js"; @@ -103,7 +103,10 @@ function resolveBoundThreadBinding(params: { return bindings.find((entry) => entry.threadId === targetChannelId); } -function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undefined): { +function resolveBindingPersona( + cfg: OpenClawConfig, + binding: DiscordThreadBindingLookupRecord | undefined, +): { username?: string; avatarUrl?: string; } { @@ -115,7 +118,7 @@ function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undef let avatarUrl: string | undefined; try { - const avatar = resolveAgentAvatar(loadConfig(), binding.agentId); + const avatar = resolveAgentAvatar(cfg, binding.agentId); if (avatar.kind === "remote") { avatarUrl = avatar.url; } @@ -126,6 +129,7 @@ function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undef } async function sendDiscordChunkWithFallback(params: { + cfg: OpenClawConfig; target: string; text: string; token: string; @@ -152,6 +156,7 @@ async function sendDiscordChunkWithFallback(params: { if (binding?.webhookId && binding?.webhookToken) { try { await sendWebhookMessageDiscord(text, { + cfg: params.cfg, webhookId: binding.webhookId, webhookToken: binding.webhookToken, accountId: binding.accountId, @@ -190,6 +195,7 @@ async function sendDiscordChunkWithFallback(params: { await sendWithRetry( () => sendMessageDiscord(params.target, text, { + cfg: params.cfg, token: params.token, rest: params.rest, accountId: params.accountId, @@ -200,6 +206,7 @@ async function sendDiscordChunkWithFallback(params: { } async function sendAdditionalDiscordMedia(params: { + cfg: OpenClawConfig; target: string; token: string; rest?: RequestClient; @@ -214,6 +221,7 @@ async function sendAdditionalDiscordMedia(params: { await sendWithRetry( () => sendMessageDiscord(params.target, "", { + cfg: params.cfg, token: params.token, rest: params.rest, mediaUrl, @@ -227,6 +235,7 @@ async function sendAdditionalDiscordMedia(params: { } export async function deliverDiscordReply(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; token: string; @@ -267,12 +276,12 @@ export async function deliverDiscordReply(params: { sessionKey: params.sessionKey, target: params.target, }); - const persona = resolveBindingPersona(binding); + const persona = resolveBindingPersona(params.cfg, binding); // Pre-resolve channel ID and retry runner once to avoid per-chunk overhead. // This eliminates redundant channel-type GET requests and client creation that // can cause ordering issues when multiple chunks share the RequestClient queue. const channelId = resolveTargetChannelId(params.target); - const account = resolveDiscordAccount({ cfg: loadConfig(), accountId: params.accountId }); + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); const retryConfig = resolveDeliveryRetryConfig(account.config.retry); const request: RetryRunner | undefined = channelId ? createDiscordRetryRunner({ configRetry: account.config.retry }) @@ -302,6 +311,7 @@ export async function deliverDiscordReply(params: { } const replyTo = resolveReplyTo(); await sendDiscordChunkWithFallback({ + cfg: params.cfg, target: params.target, text: chunk, token: params.token, @@ -331,6 +341,7 @@ export async function deliverDiscordReply(params: { if (payload.audioAsVoice) { const replyTo = resolveReplyTo(); await sendVoiceMessageDiscord(params.target, firstMedia, { + cfg: params.cfg, token: params.token, rest: params.rest, accountId: params.accountId, @@ -339,6 +350,7 @@ export async function deliverDiscordReply(params: { deliveredAny = true; // Voice messages cannot include text; send remaining text separately if present. await sendDiscordChunkWithFallback({ + cfg: params.cfg, target: params.target, text, token: params.token, @@ -356,6 +368,7 @@ export async function deliverDiscordReply(params: { }); // Additional media items are sent as regular attachments (voice is single-file only). await sendAdditionalDiscordMedia({ + cfg: params.cfg, target: params.target, token: params.token, rest: params.rest, @@ -370,6 +383,7 @@ export async function deliverDiscordReply(params: { const replyTo = resolveReplyTo(); await sendMessageDiscord(params.target, text, { + cfg: params.cfg, token: params.token, rest: params.rest, mediaUrl: firstMedia, @@ -379,6 +393,7 @@ export async function deliverDiscordReply(params: { }); deliveredAny = true; await sendAdditionalDiscordMedia({ + cfg: params.cfg, target: params.target, token: params.token, rest: params.rest, diff --git a/src/discord/monitor/thread-bindings.discord-api.test.ts b/src/discord/monitor/thread-bindings.discord-api.test.ts index 0dca4afe0b4..5b455da9e5d 100644 --- a/src/discord/monitor/thread-bindings.discord-api.test.ts +++ b/src/discord/monitor/thread-bindings.discord-api.test.ts @@ -1,8 +1,12 @@ import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const hoisted = vi.hoisted(() => { const restGet = vi.fn(); + const sendMessageDiscord = vi.fn(); + const sendWebhookMessageDiscord = vi.fn(); const createDiscordRestClient = vi.fn(() => ({ rest: { get: restGet, @@ -10,6 +14,8 @@ const hoisted = vi.hoisted(() => { })); return { restGet, + sendMessageDiscord, + sendWebhookMessageDiscord, createDiscordRestClient, }; }); @@ -18,12 +24,20 @@ vi.mock("../client.js", () => ({ createDiscordRestClient: hoisted.createDiscordRestClient, })); -const { resolveChannelIdForBinding } = await import("./thread-bindings.discord-api.js"); +vi.mock("../send.js", () => ({ + sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscord(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscord(...args), +})); + +const { maybeSendBindingMessage, resolveChannelIdForBinding } = + await import("./thread-bindings.discord-api.js"); describe("resolveChannelIdForBinding", () => { beforeEach(() => { hoisted.restGet.mockClear(); hoisted.createDiscordRestClient.mockClear(); + hoisted.sendMessageDiscord.mockClear().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({}); }); it("returns explicit channelId without resolving route", async () => { @@ -53,6 +67,26 @@ describe("resolveChannelIdForBinding", () => { expect(resolved).toBe("channel-parent"); }); + it("forwards cfg when resolving channel id through Discord client", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; + hoisted.restGet.mockResolvedValueOnce({ + id: "thread-1", + type: ChannelType.PublicThread, + parent_id: "channel-parent", + }); + + await resolveChannelIdForBinding({ + cfg, + accountId: "default", + threadId: "thread-1", + }); + + const createDiscordRestClientCalls = hoisted.createDiscordRestClient.mock.calls as unknown[][]; + expect(createDiscordRestClientCalls[0]?.[1]).toBe(cfg); + }); + it("keeps non-thread channel id even when parent_id exists", async () => { hoisted.restGet.mockResolvedValueOnce({ id: "channel-text", @@ -83,3 +117,45 @@ describe("resolveChannelIdForBinding", () => { expect(resolved).toBe("forum-1"); }); }); + +describe("maybeSendBindingMessage", () => { + beforeEach(() => { + hoisted.sendMessageDiscord.mockClear().mockResolvedValue({}); + hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({}); + }); + + it("forwards cfg to webhook send path", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; + const record = { + accountId: "default", + channelId: "parent-1", + threadId: "thread-1", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:test", + agentId: "main", + boundBy: "test", + boundAt: Date.now(), + lastActivityAt: Date.now(), + webhookId: "wh_1", + webhookToken: "tok_1", + } satisfies ThreadBindingRecord; + + await maybeSendBindingMessage({ + cfg, + record, + text: "hello webhook", + }); + + expect(hoisted.sendWebhookMessageDiscord).toHaveBeenCalledTimes(1); + expect(hoisted.sendWebhookMessageDiscord.mock.calls[0]?.[1]).toMatchObject({ + cfg, + webhookId: "wh_1", + webhookToken: "tok_1", + accountId: "default", + threadId: "thread-1", + }); + expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled(); + }); +}); diff --git a/src/discord/monitor/thread-bindings.discord-api.ts b/src/discord/monitor/thread-bindings.discord-api.ts index faac1cce4e8..2a59075cf46 100644 --- a/src/discord/monitor/thread-bindings.discord-api.ts +++ b/src/discord/monitor/thread-bindings.discord-api.ts @@ -1,4 +1,5 @@ import { ChannelType, Routes } from "discord-api-types/v10"; +import type { OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; @@ -122,6 +123,7 @@ export function isDiscordThreadGoneError(err: unknown): boolean { } export async function maybeSendBindingMessage(params: { + cfg?: OpenClawConfig; record: ThreadBindingRecord; text: string; preferWebhook?: boolean; @@ -134,6 +136,7 @@ export async function maybeSendBindingMessage(params: { if (params.preferWebhook !== false && record.webhookId && record.webhookToken) { try { await sendWebhookMessageDiscord(text, { + cfg: params.cfg, webhookId: record.webhookId, webhookToken: record.webhookToken, accountId: record.accountId, @@ -147,6 +150,7 @@ export async function maybeSendBindingMessage(params: { } try { await sendMessageDiscord(buildThreadTarget(record.threadId), text, { + cfg: params.cfg, accountId: record.accountId, }); } catch (err) { @@ -155,15 +159,19 @@ export async function maybeSendBindingMessage(params: { } export async function createWebhookForChannel(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; channelId: string; }): Promise<{ webhookId?: string; webhookToken?: string }> { try { - const rest = createDiscordRestClient({ - accountId: params.accountId, - token: params.token, - }).rest; + const rest = createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; const created = (await rest.post(Routes.channelWebhooks(params.channelId), { body: { name: "OpenClaw Agents", @@ -218,6 +226,7 @@ export function findReusableWebhook(params: { accountId: string; channelId: stri } export async function resolveChannelIdForBinding(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; threadId: string; @@ -228,10 +237,13 @@ export async function resolveChannelIdForBinding(params: { return explicit; } try { - const rest = createDiscordRestClient({ - accountId: params.accountId, - token: params.token, - }).rest; + const rest = createDiscordRestClient( + { + accountId: params.accountId, + token: params.token, + }, + params.cfg, + ).rest; const channel = (await rest.get(Routes.channel(params.threadId))) as { id?: string; type?: number; @@ -261,6 +273,7 @@ export async function resolveChannelIdForBinding(params: { } export async function createThreadForBinding(params: { + cfg?: OpenClawConfig; accountId: string; token?: string; channelId: string; @@ -274,6 +287,7 @@ export async function createThreadForBinding(params: { autoArchiveMinutes: 60, }, { + cfg: params.cfg, accountId: params.accountId, token: params.token, }, diff --git a/src/discord/monitor/thread-bindings.lifecycle.test.ts b/src/discord/monitor/thread-bindings.lifecycle.test.ts index b4eeb229f6f..6d37dcc1c2a 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.test.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../../config/config.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); @@ -68,6 +72,7 @@ const { describe("thread binding lifecycle", () => { beforeEach(() => { __testing.resetThreadBindingsForTests(); + clearRuntimeConfigSnapshot(); hoisted.sendMessageDiscord.mockClear(); hoisted.sendWebhookMessageDiscord.mockClear(); hoisted.restGet.mockClear(); @@ -627,9 +632,13 @@ describe("thread binding lifecycle", () => { }); it("passes manager token when resolving parent channels for auto-bind", async () => { + const cfg = { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig; createThreadBindingManager({ accountId: "runtime", token: "runtime-token", + cfg, persist: false, enableSweeper: false, idleTimeoutMs: 24 * 60 * 60 * 1000, @@ -647,6 +656,7 @@ describe("thread binding lifecycle", () => { hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" }); const childBinding = await autoBindSpawnedDiscordSubagent({ + cfg, accountId: "runtime", channel: "discord", to: "channel:thread-runtime", @@ -662,6 +672,73 @@ describe("thread binding lifecycle", () => { accountId: "runtime", token: "runtime-token", }); + const usedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === cfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && first !== null && (first as { cfg?: unknown }).cfg === cfg + ); + }); + expect(usedCfg).toBe(true); + }); + + it("uses the active runtime snapshot cfg for manager operations", async () => { + const startupCfg = { + channels: { discord: { token: "startup-token" } }, + } as OpenClawConfig; + const refreshedCfg = { + channels: { discord: { token: "refreshed-token" } }, + } as OpenClawConfig; + const manager = createThreadBindingManager({ + accountId: "runtime", + token: "runtime-token", + cfg: startupCfg, + persist: false, + enableSweeper: false, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }); + + setRuntimeConfigSnapshot(refreshedCfg); + hoisted.createDiscordRestClient.mockClear(); + hoisted.createThreadDiscord.mockClear(); + hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime-cfg" }); + + const bound = await manager.bindTarget({ + createThread: true, + channelId: "parent-runtime", + targetKind: "subagent", + targetSessionKey: "agent:main:subagent:runtime-cfg", + agentId: "main", + }); + + expect(bound).not.toBeNull(); + const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === refreshedCfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && + first !== null && + (first as { cfg?: unknown }).cfg === refreshedCfg + ); + }); + expect(usedRefreshedCfg).toBe(true); + const usedStartupCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => { + if (call?.[1] === startupCfg) { + return true; + } + const first = call?.[0]; + return ( + typeof first === "object" && + first !== null && + (first as { cfg?: unknown }).cfg === startupCfg + ); + }); + expect(usedStartupCfg).toBe(false); }); it("refreshes manager token when an existing manager is reused", async () => { diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/src/discord/monitor/thread-bindings.lifecycle.ts index f5beb9a3e6f..256ab5e249c 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -118,6 +118,7 @@ export function listThreadBindingsBySessionKey(params: { } export async function autoBindSpawnedDiscordSubagent(params: { + cfg?: OpenClawConfig; accountId?: string; channel?: string; to?: string; @@ -146,6 +147,7 @@ export async function autoBindSpawnedDiscordSubagent(params: { } else { channelId = (await resolveChannelIdForBinding({ + cfg: params.cfg, accountId: manager.accountId, token: managerToken, threadId: requesterThreadId, @@ -164,6 +166,7 @@ export async function autoBindSpawnedDiscordSubagent(params: { } channelId = (await resolveChannelIdForBinding({ + cfg: params.cfg, accountId: manager.accountId, token: managerToken, threadId: target.id, diff --git a/src/discord/monitor/thread-bindings.manager.ts b/src/discord/monitor/thread-bindings.manager.ts index 386d1adbc8c..43ee414c2a5 100644 --- a/src/discord/monitor/thread-bindings.manager.ts +++ b/src/discord/monitor/thread-bindings.manager.ts @@ -1,5 +1,6 @@ import { Routes } from "discord-api-types/v10"; import { resolveThreadBindingConversationIdFromBindingId } from "../../channels/thread-binding-id.js"; +import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import { registerSessionBindingAdapter, @@ -162,6 +163,7 @@ export function createThreadBindingManager( params: { accountId?: string; token?: string; + cfg?: OpenClawConfig; persist?: boolean; enableSweeper?: boolean; idleTimeoutMs?: number; @@ -188,6 +190,7 @@ export function createThreadBindingManager( params.maxAgeMs, DEFAULT_THREAD_BINDING_MAX_AGE_MS, ); + const resolveCurrentCfg = () => getRuntimeConfigSnapshot() ?? params.cfg; const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token; let sweepTimer: NodeJS.Timeout | null = null; @@ -255,6 +258,7 @@ export function createThreadBindingManager( return nextRecord; }, bindTarget: async (bindParams) => { + const cfg = resolveCurrentCfg(); let threadId = normalizeThreadId(bindParams.threadId); let channelId = bindParams.channelId?.trim() || ""; @@ -268,6 +272,7 @@ export function createThreadBindingManager( }); threadId = (await createThreadForBinding({ + cfg, accountId, token: resolveCurrentToken(), channelId, @@ -282,6 +287,7 @@ export function createThreadBindingManager( if (!channelId) { channelId = (await resolveChannelIdForBinding({ + cfg, accountId, token: resolveCurrentToken(), threadId, @@ -307,6 +313,7 @@ export function createThreadBindingManager( } if (!webhookId || !webhookToken) { const createdWebhook = await createWebhookForChannel({ + cfg, accountId, token: resolveCurrentToken(), channelId, @@ -340,7 +347,7 @@ export function createThreadBindingManager( const introText = bindParams.introText?.trim(); if (introText) { - void maybeSendBindingMessage({ record, text: introText }); + void maybeSendBindingMessage({ cfg, record, text: introText }); } return record; }, @@ -365,6 +372,7 @@ export function createThreadBindingManager( saveBindingsToDisk(); } if (unbindParams.sendFarewell !== false) { + const cfg = resolveCurrentCfg(); const farewell = resolveThreadBindingFarewellText({ reason: unbindParams.reason, farewellText: unbindParams.farewellText, @@ -379,7 +387,12 @@ export function createThreadBindingManager( }); // Use bot send path for farewell messages so unbound threads don't process // webhook echoes as fresh inbound turns when allowBots is enabled. - void maybeSendBindingMessage({ record: removed, text: farewell, preferWebhook: false }); + void maybeSendBindingMessage({ + cfg, + record: removed, + text: farewell, + preferWebhook: false, + }); } return removed; }, @@ -433,10 +446,14 @@ export function createThreadBindingManager( } let rest; try { - rest = createDiscordRestClient({ - accountId, - token: resolveCurrentToken(), - }).rest; + const cfg = resolveCurrentCfg(); + rest = createDiscordRestClient( + { + accountId, + token: resolveCurrentToken(), + }, + cfg, + ).rest; } catch { return; } @@ -561,8 +578,10 @@ export function createThreadBindingManager( if (placement === "child") { createThread = true; if (!channelId && conversationId) { + const cfg = resolveCurrentCfg(); channelId = (await resolveChannelIdForBinding({ + cfg, accountId, token: resolveCurrentToken(), threadId: conversationId, diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 306170281c8..ff4d0533c1b 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -59,6 +59,15 @@ function listExtensionFiles(): { }; } +function listHighRiskRuntimeCfgFiles(): string[] { + return [ + "src/agents/tools/telegram-actions.ts", + "src/discord/monitor/reply-delivery.ts", + "src/discord/monitor/thread-bindings.discord-api.ts", + "src/discord/monitor/thread-bindings.manager.ts", + ]; +} + function extractOutboundBlock(source: string, file: string): string { const outboundKeyIndex = source.indexOf("outbound:"); expect(outboundKeyIndex, `${file} should define outbound:`).toBeGreaterThanOrEqual(0); @@ -176,4 +185,12 @@ describe("outbound cfg-threading guard", () => { ); } }); + + it("keeps high-risk runtime delivery paths free of loadConfig calls", () => { + const runtimeFiles = listHighRiskRuntimeCfgFiles(); + for (const file of runtimeFiles) { + const source = readRepoFile(file); + expect(source, `${file} must not call loadConfig`).not.toMatch(loadConfigPattern); + } + }); }); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index fa26df0209a..44e18ee2340 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -80,6 +80,7 @@ type TelegramMessageLike = { }; type TelegramReactionOpts = { + cfg?: ReturnType; token?: string; accountId?: string; api?: TelegramApiOverride; @@ -1020,6 +1021,7 @@ export async function reactMessageTelegram( } type TelegramDeleteOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1234,6 +1236,7 @@ function inferFilename(kind: MediaKind) { } type TelegramStickerOpts = { + cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; @@ -1426,9 +1429,10 @@ export async function sendPollTelegram( // --------------------------------------------------------------------------- type TelegramCreateForumTopicOpts = { + cfg?: ReturnType; token?: string; accountId?: string; - api?: Bot["api"]; + api?: TelegramApiOverride; verbose?: boolean; retry?: RetryConfig; /** Icon color for the topic (must be one of 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F). */ @@ -1464,16 +1468,9 @@ export async function createForumTopicTelegram( throw new Error("Forum topic name must be 128 characters or fewer"); } - const cfg = loadConfig(); - const account = resolveTelegramAccount({ - cfg, - accountId: opts.accountId, - }); - const token = resolveToken(opts.token, account); + const { cfg, account, api } = resolveTelegramApiContext(opts); // Accept topic-qualified targets (e.g. telegram:group::topic:) // but createForumTopic must always target the base supergroup chat id. - const client = resolveTelegramClientOptions(account); - const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const target = parseTelegramTarget(chatId); const normalizedChatId = await resolveAndPersistChatId({ cfg, From d30dc28b8c9a48bfd12e32d772dcaab8b63be3c1 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:45:37 -0500 Subject: [PATCH 12/31] Secrets: reject exec SecretRef traversal ids across schema/runtime/gateway (#42370) * Secrets: harden exec SecretRef validation and reload LKG coverage * Tests: harden exec fast-exit stdin regression case * Tests: align lifecycle daemon test formatting with oxfmt 0.36 --- docs/gateway/configuration-reference.md | 1 + docs/gateway/secrets.md | 1 + docs/help/testing.md | 3 + src/commands/auth-choice.apply-helpers.ts | 5 + src/config/config.secrets-schema.test.ts | 31 +++ src/config/zod-schema.core.ts | 14 +- .../protocol/primitives.secretref.test.ts | 34 +++ src/gateway/protocol/schema/primitives.ts | 42 ++- src/gateway/server.reload.test.ts | 244 ++++++++++++++++++ src/plugin-sdk/secret-input-schema.test.ts | 30 +++ src/plugin-sdk/secret-input-schema.ts | 46 +++- src/secrets/configure.ts | 18 +- src/secrets/exec-secret-ref-id-parity.test.ts | 199 ++++++++++++++ src/secrets/plan.test.ts | 44 ++++ src/secrets/plan.ts | 5 +- src/secrets/ref-contract.test.ts | 33 +++ src/secrets/ref-contract.ts | 37 +++ src/secrets/resolve.test.ts | 44 +++- src/secrets/resolve.ts | 7 + src/secrets/runtime.test.ts | 23 ++ src/test-utils/secret-ref-test-vectors.ts | 24 ++ 21 files changed, 853 insertions(+), 32 deletions(-) create mode 100644 src/gateway/protocol/primitives.secretref.test.ts create mode 100644 src/plugin-sdk/secret-input-schema.test.ts create mode 100644 src/secrets/exec-secret-ref-id-parity.test.ts create mode 100644 src/secrets/ref-contract.test.ts create mode 100644 src/test-utils/secret-ref-test-vectors.ts diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 9a77f6ac1a3..5cad5acea9d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2713,6 +2713,7 @@ Validation: - `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$` - `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`) - `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$` +- `source: "exec"` ids must not contain `.` or `..` slash-delimited path segments (for example `a/../b` is rejected) ### Supported credential surface diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 213c98f9f14..76b89a0f28a 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -114,6 +114,7 @@ Validation: - `provider` must match `^[a-z][a-z0-9_-]{0,63}$` - `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$` +- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected) ## Provider config diff --git a/docs/help/testing.md b/docs/help/testing.md index 9e965b4c769..6580de4da20 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -409,3 +409,6 @@ When you fix a provider/model issue discovered in live: - Prefer targeting the smallest layer that catches the bug: - provider request conversion/replay bug → direct models test - gateway session/history/tool pipeline bug → gateway live smoke or CI-safe gateway mock test +- SecretRef traversal guardrail: + - `src/secrets/exec-secret-ref-id-parity.test.ts` derives one sampled target per SecretRef class from registry metadata (`listSecretTargetRegistryEntries()`), then asserts traversal-segment exec ids are rejected. + - If you add a new `includeInPlan` SecretRef target family in `src/secrets/target-registry-data.ts`, update `classifyTargetClass` in that test. The test intentionally fails on unclassified target ids so new classes cannot be skipped silently. diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 122be392153..32c6ac82786 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -8,6 +8,8 @@ import { import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, isValidFileSecretRefId, resolveDefaultSecretProviderAlias, } from "../secrets/ref-contract.js"; @@ -238,6 +240,9 @@ export async function promptSecretRefForOnboarding(params: { ) { return 'singleValue mode expects id "value".'; } + if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { + return formatExecSecretRefIdValidationMessage(); + } return undefined; }, }); diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index 196bb50ace4..e3c236fb15b 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; import { validateConfigObjectRaw } from "./validation.js"; function validateOpenAiApiKeyRef(apiKey: unknown) { @@ -173,4 +177,31 @@ describe("config secret refs schema", () => { ).toBe(true); } }); + + it("accepts valid exec secret reference ids", () => { + for (const id of VALID_EXEC_SECRET_REF_IDS) { + const result = validateOpenAiApiKeyRef({ + source: "exec", + provider: "vault", + id, + }); + expect(result.ok, `expected valid exec ref id: ${id}`).toBe(true); + } + }); + + it("rejects invalid exec secret reference ids", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + const result = validateOpenAiApiKeyRef({ + source: "exec", + provider: "vault", + id, + }); + expect(result.ok, `expected invalid exec ref id: ${id}`).toBe(false); + if (!result.ok) { + expect( + result.issues.some((issue) => issue.path.includes("models.providers.openai.apiKey")), + ).toBe(true); + } + } + }); }); diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 23accd81637..066a33f0f4f 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -1,14 +1,17 @@ import path from "node:path"; import { z } from "zod"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; -import { isValidFileSecretRefId } from "../secrets/ref-contract.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, +} from "../secrets/ref-contract.js"; import { MODEL_APIS } from "./types.models.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { sensitive } from "./zod-schema.sensitive.js"; const ENV_SECRET_REF_ID_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/; const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; -const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; @@ -65,12 +68,7 @@ const ExecSecretRefSchema = z SECRET_PROVIDER_ALIAS_PATTERN, 'Secret reference provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").', ), - id: z - .string() - .regex( - EXEC_SECRET_REF_ID_PATTERN, - 'Exec secret reference id must match /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/ (example: "vault/openai/api-key").', - ), + id: z.string().refine(isValidExecSecretRefId, formatExecSecretRefIdValidationMessage()), }) .strict(); diff --git a/src/gateway/protocol/primitives.secretref.test.ts b/src/gateway/protocol/primitives.secretref.test.ts new file mode 100644 index 00000000000..67f8304d48e --- /dev/null +++ b/src/gateway/protocol/primitives.secretref.test.ts @@ -0,0 +1,34 @@ +import AjvPkg from "ajv"; +import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../../test-utils/secret-ref-test-vectors.js"; +import { SecretInputSchema, SecretRefSchema } from "./schema/primitives.js"; + +describe("gateway protocol SecretRef schema", () => { + const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default; + const ajv = new Ajv({ allErrors: true, strict: false }); + const validateSecretRef = ajv.compile(SecretRefSchema); + const validateSecretInput = ajv.compile(SecretInputSchema); + + it("accepts valid source-specific refs", () => { + expect(validateSecretRef({ source: "env", provider: "default", id: "OPENAI_API_KEY" })).toBe( + true, + ); + expect( + validateSecretRef({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }), + ).toBe(true); + for (const id of VALID_EXEC_SECRET_REF_IDS) { + expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(true); + expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(true); + } + }); + + it("rejects invalid exec refs", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + expect(validateSecretRef({ source: "exec", provider: "vault", id }), id).toBe(false); + expect(validateSecretInput({ source: "exec", provider: "vault", id }), id).toBe(false); + } + }); +}); diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts index 2268d1bde50..6ac6a71b64a 100644 --- a/src/gateway/protocol/schema/primitives.ts +++ b/src/gateway/protocol/schema/primitives.ts @@ -1,4 +1,10 @@ import { Type } from "@sinclair/typebox"; +import { ENV_SECRET_REF_ID_RE } from "../../../config/types.secrets.js"; +import { + EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN, + FILE_SECRET_REF_ID_PATTERN, + SECRET_PROVIDER_ALIAS_PATTERN, +} from "../../../secrets/ref-contract.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js"; @@ -27,13 +33,41 @@ export const SecretRefSourceSchema = Type.Union([ Type.Literal("exec"), ]); -export const SecretRefSchema = Type.Object( +const SecretProviderAliasString = Type.String({ + pattern: SECRET_PROVIDER_ALIAS_PATTERN.source, +}); + +const EnvSecretRefSchema = Type.Object( { - source: SecretRefSourceSchema, - provider: NonEmptyString, - id: NonEmptyString, + source: Type.Literal("env"), + provider: SecretProviderAliasString, + id: Type.String({ pattern: ENV_SECRET_REF_ID_RE.source }), }, { additionalProperties: false }, ); +const FileSecretRefSchema = Type.Object( + { + source: Type.Literal("file"), + provider: SecretProviderAliasString, + id: Type.String({ pattern: FILE_SECRET_REF_ID_PATTERN.source }), + }, + { additionalProperties: false }, +); + +const ExecSecretRefSchema = Type.Object( + { + source: Type.Literal("exec"), + provider: SecretProviderAliasString, + id: Type.String({ pattern: EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN }), + }, + { additionalProperties: false }, +); + +export const SecretRefSchema = Type.Union([ + EnvSecretRefSchema, + FileSecretRefSchema, + ExecSecretRefSchema, +]); + export const SecretInputSchema = Type.Union([Type.String(), SecretRefSchema]); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index b3a603fa287..d62a3e90968 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -8,6 +8,7 @@ import { installGatewayTestHooks, rpcReq, startServerWithClient, + testState, withGatewayServer, } from "./test-helpers.js"; @@ -242,6 +243,94 @@ describe("gateway hot reload", () => { ); } + async function writeTalkApiKeyEnvRefConfig(refId = "TALK_API_KEY_REF") { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + talk: { + apiKey: { source: "env", provider: "default", id: refId }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + + async function writeGatewayTraversalExecRefConfig() { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + auth: { + mode: "token", + token: { source: "exec", provider: "vault", id: "a/../b" }, + }, + }, + secrets: { + providers: { + vault: { + source: "exec", + command: process.execPath, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + + async function writeGatewayTokenExecRefConfig(params: { + resolverScriptPath: string; + modePath: string; + tokenValue: string; + }) { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + throw new Error("OPENCLAW_CONFIG_PATH is not set"); + } + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + auth: { + mode: "token", + token: { source: "exec", provider: "vault", id: "gateway/token" }, + }, + }, + secrets: { + providers: { + vault: { + source: "exec", + command: process.execPath, + allowSymlinkCommand: true, + args: [params.resolverScriptPath, params.modePath, params.tokenValue], + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + } + async function writeDisabledSurfaceRefConfig() { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { @@ -485,6 +574,13 @@ describe("gateway hot reload", () => { ); }); + it("fails startup when an active exec ref id contains traversal segments", async () => { + await writeGatewayTraversalExecRefConfig(); + await expect(withGatewayServer(async () => {})).rejects.toThrow( + /must not include "\." or "\.\." path segments/i, + ); + }); + it("allows startup when unresolved refs exist only on disabled surfaces", async () => { await writeDisabledSurfaceRefConfig(); delete process.env.DISABLED_TELEGRAM_STARTUP_REF; @@ -650,6 +746,154 @@ describe("gateway hot reload", () => { await server.close(); } }); + + it("keeps last-known-good snapshot active when secrets.reload fails over RPC", async () => { + const refId = "RUNTIME_LKG_TALK_API_KEY"; + const previousRefValue = process.env[refId]; + process.env[refId] = "talk-key-before-reload-failure"; // pragma: allowlist secret + await writeTalkApiKeyEnvRefConfig(refId); + + const { server, ws } = await startServerWithClient(); + try { + await connectOk(ws); + const preResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-test", + targetIds: ["talk.apiKey"], + }); + expect(preResolve.ok).toBe(true); + expect(preResolve.payload?.assignments?.[0]?.path).toBe("talk.apiKey"); + expect(preResolve.payload?.assignments?.[0]?.value).toBe("talk-key-before-reload-failure"); + + delete process.env[refId]; + const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {}); + expect(reload.ok).toBe(false); + expect(reload.error?.code).toBe("UNAVAILABLE"); + expect(reload.error?.message ?? "").toContain(refId); + + const postResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-test", + targetIds: ["talk.apiKey"], + }); + expect(postResolve.ok).toBe(true); + expect(postResolve.payload?.assignments?.[0]?.path).toBe("talk.apiKey"); + expect(postResolve.payload?.assignments?.[0]?.value).toBe("talk-key-before-reload-failure"); + } finally { + if (previousRefValue === undefined) { + delete process.env[refId]; + } else { + process.env[refId] = previousRefValue; + } + ws.close(); + await server.close(); + } + }); + + it("keeps last-known-good auth snapshot active when gateway auth token exec reload fails", async () => { + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("OPENCLAW_STATE_DIR is not set"); + } + const resolverScriptPath = path.join(stateDir, "gateway-auth-token-resolver.cjs"); + const modePath = path.join(stateDir, "gateway-auth-token-resolver.mode"); + const tokenValue = "gateway-auth-exec-token"; + await fs.mkdir(path.dirname(resolverScriptPath), { recursive: true }); + await fs.writeFile( + resolverScriptPath, + `const fs = require("node:fs"); +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { + const modePath = process.argv[2]; + const token = process.argv[3]; + const mode = fs.existsSync(modePath) ? fs.readFileSync(modePath, "utf8").trim() : "ok"; + let ids = ["gateway/token"]; + try { + const parsed = JSON.parse(input || "{}"); + if (Array.isArray(parsed.ids) && parsed.ids.length > 0) { + ids = parsed.ids.map((entry) => String(entry)); + } + } catch {} + + if (mode === "fail") { + const errors = {}; + for (const id of ids) { + errors[id] = { message: "forced failure" }; + } + process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {}, errors }) + "\\n"); + return; + } + + const values = {}; + for (const id of ids) { + values[id] = token; + } + process.stdout.write(JSON.stringify({ protocolVersion: 1, values }) + "\\n"); +}); +`, + "utf8", + ); + await fs.writeFile(modePath, "ok\n", "utf8"); + await writeGatewayTokenExecRefConfig({ + resolverScriptPath, + modePath, + tokenValue, + }); + + const previousGatewayAuth = testState.gatewayAuth; + const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = undefined; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + + const started = await startServerWithClient(); + const { server, ws, envSnapshot } = started; + try { + await connectOk(ws, { + token: tokenValue, + }); + const preResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-auth-test", + targetIds: ["gateway.auth.token"], + }); + expect(preResolve.ok).toBe(true); + expect(preResolve.payload?.assignments?.[0]?.path).toBe("gateway.auth.token"); + expect(preResolve.payload?.assignments?.[0]?.value).toBe(tokenValue); + + await fs.writeFile(modePath, "fail\n", "utf8"); + const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {}); + expect(reload.ok).toBe(false); + expect(reload.error?.code).toBe("UNAVAILABLE"); + expect(reload.error?.message ?? "").toContain("forced failure"); + + const postResolve = await rpcReq<{ + assignments?: Array<{ path: string; pathSegments: string[]; value: unknown }>; + }>(ws, "secrets.resolve", { + commandName: "runtime-lkg-auth-test", + targetIds: ["gateway.auth.token"], + }); + expect(postResolve.ok).toBe(true); + expect(postResolve.payload?.assignments?.[0]?.path).toBe("gateway.auth.token"); + expect(postResolve.payload?.assignments?.[0]?.value).toBe(tokenValue); + } finally { + testState.gatewayAuth = previousGatewayAuth; + if (previousGatewayTokenEnv === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv; + } + envSnapshot.restore(); + ws.close(); + await server.close(); + } + }); }); describe("gateway agents", () => { diff --git a/src/plugin-sdk/secret-input-schema.test.ts b/src/plugin-sdk/secret-input-schema.test.ts new file mode 100644 index 00000000000..1a4463c830a --- /dev/null +++ b/src/plugin-sdk/secret-input-schema.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; +import { buildSecretInputSchema } from "./secret-input-schema.js"; + +describe("plugin-sdk secret input schema", () => { + const schema = buildSecretInputSchema(); + + it("accepts plaintext and valid refs", () => { + expect(schema.safeParse("sk-plain").success).toBe(true); + expect( + schema.safeParse({ source: "env", provider: "default", id: "OPENAI_API_KEY" }).success, + ).toBe(true); + expect( + schema.safeParse({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }) + .success, + ).toBe(true); + for (const id of VALID_EXEC_SECRET_REF_IDS) { + expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(true); + } + }); + + it("rejects invalid exec refs", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(false); + } + }); +}); diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts index d5eb3a0767e..579d80df441 100644 --- a/src/plugin-sdk/secret-input-schema.ts +++ b/src/plugin-sdk/secret-input-schema.ts @@ -1,12 +1,48 @@ import { z } from "zod"; +import { ENV_SECRET_REF_ID_RE } from "../config/types.secrets.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, + SECRET_PROVIDER_ALIAS_PATTERN, +} from "../secrets/ref-contract.js"; export function buildSecretInputSchema() { + const providerSchema = z + .string() + .regex( + SECRET_PROVIDER_ALIAS_PATTERN, + 'Secret reference provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").', + ); + return z.union([ z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), + z.discriminatedUnion("source", [ + z.object({ + source: z.literal("env"), + provider: providerSchema, + id: z + .string() + .regex( + ENV_SECRET_REF_ID_RE, + 'Env secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").', + ), + }), + z.object({ + source: z.literal("file"), + provider: providerSchema, + id: z + .string() + .refine( + isValidFileSecretRefId, + 'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for singleValue mode.', + ), + }), + z.object({ + source: z.literal("exec"), + provider: providerSchema, + id: z.string().refine(isValidExecSecretRefId, formatExecSecretRefIdValidationMessage()), + }), + ]), ]); } diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index 0934c603c2d..a07d3b45903 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -20,7 +20,12 @@ import { } from "./configure-plan.js"; import type { SecretsApplyPlan } from "./plan.js"; import { PROVIDER_ENV_VARS } from "./provider-env-vars.js"; -import { isValidSecretProviderAlias, resolveDefaultSecretProviderAlias } from "./ref-contract.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidSecretProviderAlias, + resolveDefaultSecretProviderAlias, +} from "./ref-contract.js"; import { resolveSecretRefValue } from "./resolve.js"; import { assertExpectedResolvedSecretValue } from "./secret-value.js"; import { isRecord } from "./shared.js"; @@ -917,7 +922,16 @@ export async function runSecretsConfigureInteractive( await text({ message: "Secret id", initialValue: suggestedId, - validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"), + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + if (source === "exec" && !isValidExecSecretRefId(trimmed)) { + return formatExecSecretRefIdValidationMessage(); + } + return undefined; + }, }), "Secrets configure cancelled.", ); diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts new file mode 100644 index 00000000000..c3d9cb10fbc --- /dev/null +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -0,0 +1,199 @@ +import AjvPkg from "ajv"; +import { describe, expect, it } from "vitest"; +import { validateConfigObjectRaw } from "../config/validation.js"; +import { SecretRefSchema as GatewaySecretRefSchema } from "../gateway/protocol/schema/primitives.js"; +import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; +import { isSecretsApplyPlan } from "./plan.js"; +import { isValidExecSecretRefId } from "./ref-contract.js"; +import { materializePathTokens, parsePathPattern } from "./target-registry-pattern.js"; +import { listSecretTargetRegistryEntries } from "./target-registry.js"; + +describe("exec SecretRef id parity", () => { + const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default; + const ajv = new Ajv({ allErrors: true, strict: false }); + const validateGatewaySecretRef = ajv.compile(GatewaySecretRefSchema); + const pluginSdkSecretInput = buildSecretInputSchema(); + + function configAcceptsExecRef(id: string): boolean { + const result = validateConfigObjectRaw({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "exec", provider: "vault", id }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + return result.ok; + } + + function planAcceptsExecRef(id: string): boolean { + return isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: "talk.apiKey", + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + ref: { source: "exec", provider: "vault", id }, + }, + ], + }); + } + + for (const id of [...VALID_EXEC_SECRET_REF_IDS, ...INVALID_EXEC_SECRET_REF_IDS]) { + it(`keeps config/plan/gateway/plugin parity for exec id "${id}"`, () => { + const expected = isValidExecSecretRefId(id); + expect(configAcceptsExecRef(id)).toBe(expected); + expect(planAcceptsExecRef(id)).toBe(expected); + expect(validateGatewaySecretRef({ source: "exec", provider: "vault", id })).toBe(expected); + expect( + pluginSdkSecretInput.safeParse({ source: "exec", provider: "vault", id }).success, + ).toBe(expected); + }); + } + + function classifyTargetClass(id: string): string { + if (id.startsWith("auth-profiles.")) { + return "auth-profiles"; + } + if (id.startsWith("agents.")) { + return "agents"; + } + if (id.startsWith("channels.")) { + return "channels"; + } + if (id.startsWith("cron.")) { + return "cron"; + } + if (id.startsWith("gateway.auth.")) { + return "gateway.auth"; + } + if (id.startsWith("gateway.remote.")) { + return "gateway.remote"; + } + if (id.startsWith("messages.")) { + return "messages"; + } + if (id.startsWith("models.providers.") && id.includes(".headers.")) { + return "models.headers"; + } + if (id.startsWith("models.providers.")) { + return "models.apiKey"; + } + if (id.startsWith("skills.entries.")) { + return "skills"; + } + if (id.startsWith("talk.")) { + return "talk"; + } + if (id.startsWith("tools.web.fetch.")) { + return "tools.web.fetch"; + } + if (id.startsWith("tools.web.search.")) { + return "tools.web.search"; + } + return "unclassified"; + } + + function samplePathSegments(pathPattern: string): string[] { + const tokens = parsePathPattern(pathPattern); + const captures = tokens.flatMap((token) => { + if (token.kind === "literal") { + return []; + } + return [token.kind === "array" ? "0" : "sample"]; + }); + const segments = materializePathTokens(tokens, captures); + if (!segments) { + throw new Error(`failed to sample path segments for pattern "${pathPattern}"`); + } + return segments; + } + + const registryPlanTargets = listSecretTargetRegistryEntries().filter( + (entry) => entry.includeInPlan, + ); + const unclassifiedTargetIds = registryPlanTargets + .filter((entry) => classifyTargetClass(entry.id) === "unclassified") + .map((entry) => entry.id); + const sampledTargetsByClass = [ + ...new Set(registryPlanTargets.map((entry) => classifyTargetClass(entry.id))), + ] + .toSorted((a, b) => a.localeCompare(b)) + .map((className) => { + const candidates = registryPlanTargets + .filter((entry) => classifyTargetClass(entry.id) === className) + .toSorted((a, b) => a.id.localeCompare(b.id)); + const selected = candidates[0]; + if (!selected) { + throw new Error(`missing sampled target for class "${className}"`); + } + const pathSegments = samplePathSegments(selected.pathPattern); + return { + className, + id: selected.id, + type: selected.targetType, + configFile: selected.configFile, + pathSegments, + }; + }); + + function planAcceptsExecRefForSample(params: { + type: string; + configFile: "openclaw.json" | "auth-profiles.json"; + pathSegments: string[]; + id: string; + }): boolean { + return isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: params.type, + path: params.pathSegments.join("."), + pathSegments: params.pathSegments, + ref: { source: "exec", provider: "vault", id: params.id }, + ...(params.configFile === "auth-profiles.json" ? { agentId: "main" } : {}), + }, + ], + }); + } + + it("derives sampled class coverage from target registry metadata", () => { + expect(unclassifiedTargetIds).toEqual([]); + expect(sampledTargetsByClass.length).toBeGreaterThan(0); + }); + + for (const sample of sampledTargetsByClass) { + it(`rejects traversal-segment exec ids for sampled class "${sample.className}" (example: "${sample.id}")`, () => { + expect( + planAcceptsExecRefForSample({ + type: sample.type, + configFile: sample.configFile, + pathSegments: sample.pathSegments, + id: "vault/openai/apiKey", + }), + ).toBe(true); + expect( + planAcceptsExecRefForSample({ + type: sample.type, + configFile: sample.configFile, + pathSegments: sample.pathSegments, + id: "vault/../apiKey", + }), + ).toBe(false); + }); + } +}); diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 01ee81ea551..ec4d2c8dcba 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js"; describe("secrets plan validation", () => { @@ -98,4 +102,44 @@ describe("secrets plan validation", () => { }); expect(withAgent).toBe(true); }); + + it("accepts valid exec secret ref ids in plans", () => { + for (const id of VALID_EXEC_SECRET_REF_IDS) { + const isValid = isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: "talk.apiKey", + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + ref: { source: "exec", provider: "vault", id }, + }, + ], + }); + expect(isValid, `expected valid plan exec ref id: ${id}`).toBe(true); + } + }); + + it("rejects invalid exec secret ref ids in plans", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + const isValid = isSecretsApplyPlan({ + version: 1, + protocolVersion: 1, + generatedAt: "2026-03-10T00:00:00.000Z", + generatedBy: "manual", + targets: [ + { + type: "talk.apiKey", + path: "talk.apiKey", + pathSegments: ["talk", "apiKey"], + ref: { source: "exec", provider: "vault", id }, + }, + ], + }); + expect(isValid, `expected invalid plan exec ref id: ${id}`).toBe(false); + } + }); }); diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 3101e1b7828..4f576a4f25f 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -1,6 +1,6 @@ import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; -import { isValidSecretProviderAlias } from "./ref-contract.js"; +import { isValidExecSecretRefId, isValidSecretProviderAlias } from "./ref-contract.js"; import { parseDotPath, toDotPath } from "./shared.js"; import { isKnownSecretTargetType, @@ -140,7 +140,8 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan { typeof ref.provider !== "string" || ref.provider.trim().length === 0 || typeof ref.id !== "string" || - ref.id.trim().length === 0 + ref.id.trim().length === 0 || + (ref.source === "exec" && !isValidExecSecretRefId(ref.id)) ) { return false; } diff --git a/src/secrets/ref-contract.test.ts b/src/secrets/ref-contract.test.ts new file mode 100644 index 00000000000..2820ee71f46 --- /dev/null +++ b/src/secrets/ref-contract.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + INVALID_EXEC_SECRET_REF_IDS, + VALID_EXEC_SECRET_REF_IDS, +} from "../test-utils/secret-ref-test-vectors.js"; +import { isValidExecSecretRefId, validateExecSecretRefId } from "./ref-contract.js"; + +describe("exec secret ref id validation", () => { + it("accepts valid exec secret ref ids", () => { + for (const id of VALID_EXEC_SECRET_REF_IDS) { + expect(isValidExecSecretRefId(id), `expected valid id: ${id}`).toBe(true); + expect(validateExecSecretRefId(id)).toEqual({ ok: true }); + } + }); + + it("rejects invalid exec secret ref ids", () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + expect(isValidExecSecretRefId(id), `expected invalid id: ${id}`).toBe(false); + expect(validateExecSecretRefId(id).ok).toBe(false); + } + }); + + it("reports traversal segment failures separately", () => { + expect(validateExecSecretRefId("a/../b")).toEqual({ + ok: false, + reason: "traversal-segment", + }); + expect(validateExecSecretRefId("a/./b")).toEqual({ + ok: false, + reason: "traversal-segment", + }); + }); +}); diff --git a/src/secrets/ref-contract.ts b/src/secrets/ref-contract.ts index cd08b40a847..946e9ca1bb3 100644 --- a/src/secrets/ref-contract.ts +++ b/src/secrets/ref-contract.ts @@ -6,8 +6,21 @@ import { const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/; export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; +const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; export const SINGLE_VALUE_FILE_REF_ID = "value"; +export const FILE_SECRET_REF_ID_PATTERN = /^(?:value|\/(?:[^~]|~0|~1)*(?:\/(?:[^~]|~0|~1)*)*)$/; +export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN = + "^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$"; + +export type ExecSecretRefIdValidationReason = "pattern" | "traversal-segment"; + +export type ExecSecretRefIdValidationResult = + | { ok: true } + | { + ok: false; + reason: ExecSecretRefIdValidationReason; + }; export type SecretRefDefaultsCarrier = { secrets?: { @@ -69,3 +82,27 @@ export function isValidFileSecretRefId(value: string): boolean { export function isValidSecretProviderAlias(value: string): boolean { return SECRET_PROVIDER_ALIAS_PATTERN.test(value); } + +export function validateExecSecretRefId(value: string): ExecSecretRefIdValidationResult { + if (!EXEC_SECRET_REF_ID_PATTERN.test(value)) { + return { ok: false, reason: "pattern" }; + } + for (const segment of value.split("/")) { + if (segment === "." || segment === "..") { + return { ok: false, reason: "traversal-segment" }; + } + } + return { ok: true }; +} + +export function isValidExecSecretRefId(value: string): boolean { + return validateExecSecretRefId(value).ok; +} + +export function formatExecSecretRefIdValidationMessage(): string { + return [ + "Exec secret reference id must match /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/", + 'and must not include "." or ".." path segments', + '(example: "vault/openai/api-key").', + ].join(" "); +} diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 7b74e582b85..7f906d00919 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -3,7 +3,12 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js"; +import { INVALID_EXEC_SECRET_REF_IDS } from "../test-utils/secret-ref-test-vectors.js"; +import { + resolveSecretRefString, + resolveSecretRefValue, + resolveSecretRefValues, +} from "./resolve.js"; async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); @@ -232,12 +237,16 @@ describe("secret ref resolver", () => { expect(value).toBe("plain-secret"); }); - itPosix("ignores EPIPE when exec provider exits before consuming stdin", async () => { - const oversizedId = `openai/${"x".repeat(120_000)}`; - await expect( - resolveSecretRefString( - { source: "exec", provider: "execmain", id: oversizedId }, - { + itPosix( + "tolerates stdin write errors when exec provider exits before consuming a large request", + async () => { + const refs = Array.from({ length: 256 }, (_, index) => ({ + source: "exec" as const, + provider: "execmain", + id: `openai/${String(index).padStart(3, "0")}/${"x".repeat(240)}`, + })); + await expect( + resolveSecretRefValues(refs, { config: { secrets: { providers: { @@ -248,10 +257,10 @@ describe("secret ref resolver", () => { }, }, }, - }, - ), - ).rejects.toThrow('Exec provider "execmain" returned empty stdout.'); - }); + }), + ).rejects.toThrow('Exec provider "execmain" returned empty stdout.'); + }, + ); itPosix("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { const root = await createCaseDir("exec-link-reject"); @@ -432,4 +441,17 @@ describe("secret ref resolver", () => { ), ).rejects.toThrow('has source "env" but ref requests "exec"'); }); + + it("rejects invalid exec ids before provider resolution", async () => { + for (const id of INVALID_EXEC_SECRET_REF_IDS) { + await expect( + resolveSecretRefValue( + { source: "exec", provider: "vault", id }, + { + config: {}, + }, + ), + ).rejects.toThrow(/Exec secret reference id must match|Secret reference id is empty/); + } + }); }); diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 039875c464c..103075b8cd9 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -15,6 +15,8 @@ import { resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { readJsonPointer } from "./json-pointer.js"; import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, SINGLE_VALUE_FILE_REF_ID, resolveDefaultSecretProviderAlias, secretRefKey, @@ -843,6 +845,11 @@ export async function resolveSecretRefValues( if (!id) { throw new Error("Secret reference id is empty."); } + if (ref.source === "exec" && !isValidExecSecretRefId(id)) { + throw new Error( + `${formatExecSecretRefIdValidationMessage()} (ref: ${ref.source}:${ref.provider}:${id}).`, + ); + } uniqueRefs.set(secretRefKey(ref), { ...ref, id }); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index f03ce73da3e..47628f1bfe2 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -1134,6 +1134,29 @@ describe("secrets runtime snapshot", () => { ).rejects.toThrow(/MISSING_GATEWAY_TOKEN_REF/i); }); + it("fails when an active exec ref id contains traversal segments", async () => { + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({ + talk: { + apiKey: { source: "exec", provider: "vault", id: "a/../b" }, + }, + secrets: { + providers: { + vault: { + source: "exec", + command: process.execPath, + }, + }, + }, + }), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }), + ).rejects.toThrow(/must not include "\." or "\.\." path segments/i); + }); + it("treats gateway.auth.password ref as inactive when auth mode is trusted-proxy", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ diff --git a/src/test-utils/secret-ref-test-vectors.ts b/src/test-utils/secret-ref-test-vectors.ts new file mode 100644 index 00000000000..7645f4c24f2 --- /dev/null +++ b/src/test-utils/secret-ref-test-vectors.ts @@ -0,0 +1,24 @@ +export const VALID_EXEC_SECRET_REF_IDS = [ + "vault/openai/api-key", + "vault:secret/mykey", + "providers/openai/apiKey", + "a..b/c", + "a/.../b", + "a/.well-known/key", + `a/${"b".repeat(254)}`, +] as const; + +export const INVALID_EXEC_SECRET_REF_IDS = [ + "", + " ", + "a/../b", + "a/./b", + "../b", + "./b", + "a/..", + "a/.", + "/absolute/path", + "bad id", + "a\\b", + `a${"b".repeat(256)}`, +] as const; From b205de6154e06360268726dd0745b0bb074cfde2 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:52:50 -0500 Subject: [PATCH 13/31] Docs: add changelog entry for SecretRef traversal (#42455) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f571691a7e7..5ad3c0eec26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai - Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung. - Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. - Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. +- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. ## 2026.3.8 From 0ff184397da86a64f613e96f62935428706925c9 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 10 Mar 2026 21:56:30 +0300 Subject: [PATCH 14/31] docs(telegram): clarify group and sender allowlists (#42451) Merged via squash. Prepared head SHA: f30cacafb326a1ed0ef996424f049ae7b36ff1a6 Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + docs/channels/telegram.md | 26 ++++++++++++++++++++++++++ src/telegram/bot-access.ts | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad3c0eec26..2a5d12840ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode. - Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. - Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. +- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. ## 2026.3.8 diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index b29ec3c59d5..7c32c29ab19 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -155,6 +155,7 @@ curl "https://api.telegram.org/bot/getUpdates" `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. `groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized). + Do not put Telegram group or supergroup chat IDs in `groupAllowFrom`. Negative chat IDs belong under `channels.telegram.groups`. Non-numeric entries are ignored for sender authorization. Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals. Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`. @@ -177,6 +178,31 @@ curl "https://api.telegram.org/bot/getUpdates" } ``` + Example: allow only specific users inside one specific group: + +```json5 +{ + channels: { + telegram: { + groups: { + "-1001234567890": { + requireMention: true, + allowFrom: ["8734062810", "745123456"], + }, + }, + }, + }, +} +``` + + + Common mistake: `groupAllowFrom` is not a Telegram group allowlist. + + - Put negative Telegram group or supergroup chat IDs like `-1001234567890` under `channels.telegram.groups`. + - Put Telegram user IDs like `8734062810` under `groupAllowFrom` when you want to limit which people inside an allowed group can trigger the bot. + - Use `groupAllowFrom: ["*"]` only when you want any member of an allowed group to be able to talk to the bot. + + diff --git a/src/telegram/bot-access.ts b/src/telegram/bot-access.ts index d08a54616f0..60b3f5582a9 100644 --- a/src/telegram/bot-access.ts +++ b/src/telegram/bot-access.ts @@ -31,7 +31,8 @@ function warnInvalidAllowFromEntries(entries: string[]) { [ "Invalid allowFrom entry:", JSON.stringify(entry), - "- allowFrom/groupAllowFrom authorization requires numeric Telegram sender IDs only.", + "- allowFrom/groupAllowFrom authorization expects numeric Telegram sender user IDs only.", + 'To allow a Telegram group or supergroup, add its negative chat ID under "channels.telegram.groups" instead.', 'If you had "@username" entries, re-run onboarding (it resolves @username to IDs) or replace them manually.', ].join(" "), ); From 8ba1b6eff19b31f24c81a52b90966cca689c4e8b Mon Sep 17 00:00:00 2001 From: Onur Date: Tue, 10 Mar 2026 20:09:25 +0100 Subject: [PATCH 15/31] ci: add npm release workflow and CalVer checks (#42414) (thanks @onutc) --- .github/workflows/openclaw-npm-release.yml | 79 +++++++ .gitignore | 1 + docs/reference/RELEASING.md | 34 ++- package.json | 1 + scripts/openclaw-npm-release-check.ts | 251 +++++++++++++++++++++ test/openclaw-npm-release-check.test.ts | 92 ++++++++ 6 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/openclaw-npm-release.yml create mode 100644 scripts/openclaw-npm-release-check.ts create mode 100644 test/openclaw-npm-release-check.test.ts diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml new file mode 100644 index 00000000000..09126ed6ad2 --- /dev/null +++ b/.github/workflows/openclaw-npm-release.yml @@ -0,0 +1,79 @@ +name: OpenClaw NPM Release + +on: + push: + tags: + - "v*" + +concurrency: + group: openclaw-npm-release-${{ github.ref }} + cancel-in-progress: false + +env: + NODE_VERSION: "22.x" + PNPM_VERSION: "10.23.0" + +jobs: + publish_openclaw_npm: + # npm trusted publishing + provenance requires a GitHub-hosted runner. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + install-bun: "false" + use-sticky-disk: "false" + + - name: Validate release tag and package metadata + env: + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_MAIN_REF: origin/main + run: | + set -euo pipefail + # Fetch the full main ref so merge-base ancestry checks keep working + # for older tagged commits that are still contained in main. + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + pnpm release:openclaw:npm:check + + - name: Ensure version is not already published + run: | + set -euo pipefail + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + echo "Publishing openclaw@${PACKAGE_VERSION}" + + - name: Check + run: pnpm check + + - name: Build + run: pnpm build + + - name: Verify release contents + run: pnpm release:check + + - name: Publish + run: | + set -euo pipefail + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then + npm publish --access public --tag beta --provenance + else + npm publish --access public --provenance + fi diff --git a/.gitignore b/.gitignore index 0627a573c79..4defa8acb33 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ apps/ios/*.mobileprovision # Local untracked files .local/ docs/.local/ +tmp/ IDENTITY.md USER.md .tgz diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 6b5dc29c9b9..b13803e69f3 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -19,6 +19,32 @@ When the operator says “release”, immediately do this preflight (no extra qu - Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`). - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. +## Versioning + +Current OpenClaw releases use date-based versioning. + +- Stable release version: `YYYY.M.D` + - Git tag: `vYYYY.M.D` + - Examples from repo history: `v2026.2.26`, `v2026.3.8` +- Beta prerelease version: `YYYY.M.D-beta.N` + - Git tag: `vYYYY.M.D-beta.N` + - Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1` +- Use the same version string everywhere, minus the leading `v` where Git tags are not used: + - `package.json`: `2026.3.8` + - Git tag: `v2026.3.8` + - GitHub release title: `openclaw 2026.3.8` +- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`. +- Stable and beta are npm dist-tags, not separate release lines: + - `latest` = stable + - `beta` = prerelease/testing +- Dev is the moving head of `main`, not a normal git-tagged release. +- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. + +Historical note: + +- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history. +- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta. + 1. **Version & metadata** - [ ] Bump `package.json` version (e.g., `2026.1.29`). @@ -67,8 +93,11 @@ When the operator says “release”, immediately do this preflight (no extra qu 6. **Publish (npm)** - [ ] Confirm git status is clean; commit and push as needed. -- [ ] `npm login` (verify 2FA) if needed. -- [ ] `npm publish --access public` (use `--tag beta` for pre-releases). +- [ ] Confirm npm trusted publishing is configured for the `openclaw` package. +- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`. + - Stable tags publish to npm `latest`. + - Beta tags publish to npm `beta`. + - The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. - [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). ### Troubleshooting (notes from 2.0.0-beta2 release) @@ -84,6 +113,7 @@ When the operator says “release”, immediately do this preflight (no extra qu 7. **GitHub release + appcast** - [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`). + - Pushing the tag also triggers the npm release workflow. - [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**. - [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated). - [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main). diff --git a/package.json b/package.json index bc625b74e71..43fd734092a 100644 --- a/package.json +++ b/package.json @@ -295,6 +295,7 @@ "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", "release:check": "node --import tsx scripts/release-check.ts", + "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "start": "node scripts/run-node.mjs", "test": "node scripts/test-parallel.mjs", "test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all", diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts new file mode 100644 index 00000000000..267558a0d0d --- /dev/null +++ b/scripts/openclaw-npm-release-check.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env -S node --import tsx + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +type PackageJson = { + name?: string; + version?: string; + description?: string; + license?: string; + repository?: { url?: string } | string; + bin?: Record; +}; + +export type ParsedReleaseVersion = { + version: string; + channel: "stable" | "beta"; + year: number; + month: number; + day: number; + betaNumber?: number; + date: Date; +}; + +const STABLE_VERSION_REGEX = /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)$/; +const BETA_VERSION_REGEX = + /^(?\d{4})\.(?[1-9]\d?)\.(?[1-9]\d?)-beta\.(?[1-9]\d*)$/; +const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw"; +const MAX_CALVER_DISTANCE_DAYS = 2; + +function normalizeRepoUrl(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + + return value + .trim() + .replace(/^git\+/, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); +} + +function parseDateParts( + version: string, + groups: Record, + channel: "stable" | "beta", +): ParsedReleaseVersion | null { + const year = Number.parseInt(groups.year ?? "", 10); + const month = Number.parseInt(groups.month ?? "", 10); + const day = Number.parseInt(groups.day ?? "", 10); + const betaNumber = channel === "beta" ? Number.parseInt(groups.beta ?? "", 10) : undefined; + + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) || + month < 1 || + month > 12 || + day < 1 || + day > 31 + ) { + return null; + } + if (channel === "beta" && (!Number.isInteger(betaNumber) || (betaNumber ?? 0) < 1)) { + return null; + } + + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + + return { + version, + channel, + year, + month, + day, + betaNumber, + date, + }; +} + +export function parseReleaseVersion(version: string): ParsedReleaseVersion | null { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const stableMatch = STABLE_VERSION_REGEX.exec(trimmed); + if (stableMatch?.groups) { + return parseDateParts(trimmed, stableMatch.groups, "stable"); + } + + const betaMatch = BETA_VERSION_REGEX.exec(trimmed); + if (betaMatch?.groups) { + return parseDateParts(trimmed, betaMatch.groups, "beta"); + } + + return null; +} + +function startOfUtcDay(date: Date): number { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +} + +export function utcCalendarDayDistance(left: Date, right: Date): number { + return Math.round(Math.abs(startOfUtcDay(left) - startOfUtcDay(right)) / 86_400_000); +} + +export function collectReleasePackageMetadataErrors(pkg: PackageJson): string[] { + const actualRepositoryUrl = normalizeRepoUrl( + typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url, + ); + const errors: string[] = []; + + if (pkg.name !== "openclaw") { + errors.push(`package.json name must be "openclaw"; found "${pkg.name ?? ""}".`); + } + if (!pkg.description?.trim()) { + errors.push("package.json description must be non-empty."); + } + if (pkg.license !== "MIT") { + errors.push(`package.json license must be "MIT"; found "${pkg.license ?? ""}".`); + } + if (actualRepositoryUrl !== EXPECTED_REPOSITORY_URL) { + errors.push( + `package.json repository.url must resolve to ${EXPECTED_REPOSITORY_URL}; found ${ + actualRepositoryUrl || "" + }.`, + ); + } + if (pkg.bin?.openclaw !== "openclaw.mjs") { + errors.push( + `package.json bin.openclaw must be "openclaw.mjs"; found "${pkg.bin?.openclaw ?? ""}".`, + ); + } + + return errors; +} + +export function collectReleaseTagErrors(params: { + packageVersion: string; + releaseTag: string; + releaseSha?: string; + releaseMainRef?: string; + now?: Date; +}): string[] { + const errors: string[] = []; + const releaseTag = params.releaseTag.trim(); + const packageVersion = params.packageVersion.trim(); + const now = params.now ?? new Date(); + + const parsedVersion = parseReleaseVersion(packageVersion); + if (parsedVersion === null) { + errors.push( + `package.json version must match YYYY.M.D or YYYY.M.D-beta.N; found "${packageVersion || ""}".`, + ); + } + + if (!releaseTag.startsWith("v")) { + errors.push(`Release tag must start with "v"; found "${releaseTag || ""}".`); + } + + const tagVersion = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag; + const parsedTag = parseReleaseVersion(tagVersion); + if (parsedTag === null) { + errors.push( + `Release tag must match vYYYY.M.D or vYYYY.M.D-beta.N; found "${releaseTag || ""}".`, + ); + } + + const expectedTag = packageVersion ? `v${packageVersion}` : ""; + if (releaseTag !== expectedTag) { + errors.push( + `Release tag ${releaseTag || ""} does not match package.json version ${ + packageVersion || "" + }; expected ${expectedTag || ""}.`, + ); + } + + if (parsedVersion !== null) { + const dayDistance = utcCalendarDayDistance(parsedVersion.date, now); + if (dayDistance > MAX_CALVER_DISTANCE_DAYS) { + const nowLabel = now.toISOString().slice(0, 10); + const versionDate = parsedVersion.date.toISOString().slice(0, 10); + errors.push( + `Release version ${packageVersion} is ${dayDistance} days away from current UTC date ${nowLabel}; release CalVer date ${versionDate} must be within ${MAX_CALVER_DISTANCE_DAYS} days.`, + ); + } + } + + if (params.releaseSha?.trim() && params.releaseMainRef?.trim()) { + try { + execFileSync( + "git", + ["merge-base", "--is-ancestor", params.releaseSha, params.releaseMainRef], + { stdio: "ignore" }, + ); + } catch { + errors.push( + `Tagged commit ${params.releaseSha} is not contained in ${params.releaseMainRef}.`, + ); + } + } + + return errors; +} + +function loadPackageJson(): PackageJson { + return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson; +} + +function main(): number { + const pkg = loadPackageJson(); + const metadataErrors = collectReleasePackageMetadataErrors(pkg); + const tagErrors = collectReleaseTagErrors({ + packageVersion: pkg.version ?? "", + releaseTag: process.env.RELEASE_TAG ?? "", + releaseSha: process.env.RELEASE_SHA, + releaseMainRef: process.env.RELEASE_MAIN_REF, + }); + const errors = [...metadataErrors, ...tagErrors]; + + if (errors.length > 0) { + for (const error of errors) { + console.error(`openclaw-npm-release-check: ${error}`); + } + return 1; + } + + const parsedVersion = parseReleaseVersion(pkg.version ?? ""); + const channel = parsedVersion?.channel ?? "unknown"; + const dayDistance = + parsedVersion === null + ? "unknown" + : String(utcCalendarDayDistance(parsedVersion.date, new Date())); + console.log( + `openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta).`, + ); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exit(main()); +} diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts new file mode 100644 index 00000000000..7bd1c98d92d --- /dev/null +++ b/test/openclaw-npm-release-check.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + collectReleasePackageMetadataErrors, + collectReleaseTagErrors, + parseReleaseVersion, + utcCalendarDayDistance, +} from "../scripts/openclaw-npm-release-check.ts"; + +describe("parseReleaseVersion", () => { + it("parses stable CalVer releases", () => { + expect(parseReleaseVersion("2026.3.9")).toMatchObject({ + version: "2026.3.9", + channel: "stable", + year: 2026, + month: 3, + day: 9, + }); + }); + + it("parses beta CalVer releases", () => { + expect(parseReleaseVersion("2026.3.9-beta.2")).toMatchObject({ + version: "2026.3.9-beta.2", + channel: "beta", + year: 2026, + month: 3, + day: 9, + betaNumber: 2, + }); + }); + + it("rejects legacy and malformed release formats", () => { + expect(parseReleaseVersion("2026.3.9-1")).toBeNull(); + expect(parseReleaseVersion("2026.03.09")).toBeNull(); + expect(parseReleaseVersion("v2026.3.9")).toBeNull(); + expect(parseReleaseVersion("2026.2.30")).toBeNull(); + expect(parseReleaseVersion("2.0.0-beta2")).toBeNull(); + }); +}); + +describe("utcCalendarDayDistance", () => { + it("compares UTC calendar days rather than wall-clock hours", () => { + const left = new Date("2026-03-09T23:59:59Z"); + const right = new Date("2026-03-11T00:00:01Z"); + expect(utcCalendarDayDistance(left, right)).toBe(2); + }); +}); + +describe("collectReleaseTagErrors", () => { + it("accepts versions within the two-day CalVer window", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9", + now: new Date("2026-03-11T12:00:00Z"), + }), + ).toEqual([]); + }); + + it("rejects versions outside the two-day CalVer window", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9", + now: new Date("2026-03-12T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("must be within 2 days")); + }); + + it("rejects tags that do not match the current release format", () => { + expect( + collectReleaseTagErrors({ + packageVersion: "2026.3.9", + releaseTag: "v2026.3.9-1", + now: new Date("2026-03-09T00:00:00Z"), + }), + ).toContainEqual(expect.stringContaining("must match vYYYY.M.D or vYYYY.M.D-beta.N")); + }); +}); + +describe("collectReleasePackageMetadataErrors", () => { + it("validates the expected npm package metadata", () => { + expect( + collectReleasePackageMetadataErrors({ + name: "openclaw", + description: "Multi-channel AI gateway with extensible messaging integrations", + license: "MIT", + repository: { url: "git+https://github.com/openclaw/openclaw.git" }, + bin: { openclaw: "openclaw.mjs" }, + }), + ).toEqual([]); + }); +}); From 67746a12de1b2ce5a7c88f8558fa4ab9e687dc49 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Mon, 9 Mar 2026 22:10:01 +0100 Subject: [PATCH 16/31] iOS: add welcome home canvas --- CHANGELOG.md | 2 + .../Sources/Model/NodeAppModel+Canvas.swift | 17 +- apps/ios/Sources/Model/NodeAppModel.swift | 11 + apps/ios/Sources/RootCanvas.swift | 252 +++++-- .../ios/Sources/Screen/ScreenController.swift | 22 + apps/ios/Sources/Screen/ScreenWebView.swift | 1 + apps/ios/Sources/Status/StatusPill.swift | 37 +- .../Resources/CanvasScaffold/scaffold.html | 619 ++++++++++++++---- 8 files changed, 769 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5d12840ba..ac60f101edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai - Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle. - ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn. - Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc. +- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman. +- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman. ### Breaking diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 73e13fa0992..028983d1a5b 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -34,18 +34,11 @@ extension NodeAppModel { } func showA2UIOnConnectIfNeeded() async { - let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines) - if current.isEmpty || current == self.lastAutoA2uiURL { - if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(), - let url = URL(string: canvasUrl), - await Self.probeTCP(url: url, timeoutSeconds: 2.5) - { - self.screen.navigate(to: canvasUrl) - self.lastAutoA2uiURL = canvasUrl - } else { - self.lastAutoA2uiURL = nil - self.screen.showDefaultCanvas() - } + await MainActor.run { + // Keep the bundled home canvas as the default connected view. + // Agents can still explicitly present a remote or local canvas later. + self.lastAutoA2uiURL = nil + self.screen.showDefaultCanvas() } } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4b9483e7662..bbb79d98db0 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -88,6 +88,7 @@ final class NodeAppModel { var selectedAgentId: String? var gatewayDefaultAgentId: String? var gatewayAgents: [AgentSummary] = [] + var homeCanvasRevision: Int = 0 var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? @@ -548,6 +549,7 @@ final class NodeAppModel { self.seamColorHex = raw.isEmpty ? nil : raw self.mainSessionBaseKey = mainKey self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } catch { if let gatewayError = error as? GatewayResponseError { @@ -574,12 +576,19 @@ final class NodeAppModel { self.selectedAgentId = nil } self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } catch { // Best-effort only. } } + func refreshGatewayOverviewIfConnected() async { + guard await self.isOperatorConnected() else { return } + await self.refreshBrandingFromGateway() + await self.refreshAgentsFromGateway() + } + func setSelectedAgentId(_ agentId: String?) { let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -590,6 +599,7 @@ final class NodeAppModel { GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 if let relay = ShareGatewayRelaySettings.loadConfig() { ShareGatewayRelaySettings.saveConfig( ShareGatewayRelayConfig( @@ -1749,6 +1759,7 @@ private extension NodeAppModel { self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.homeCanvasRevision &+= 1 self.apnsLastRegisteredTokenHex = nil } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 1eb8459a642..64e3d739b96 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import OpenClawProtocol struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel @@ -137,16 +138,33 @@ struct RootCanvas: View { .environment(self.gatewayController) } .onAppear { self.updateIdleTimer() } + .onAppear { self.updateHomeCanvasState() } .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } - .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } + .onChange(of: self.scenePhase) { _, newValue in + self.updateIdleTimer() + self.updateHomeCanvasState() + guard newValue == .active else { return } + Task { + await self.appModel.refreshGatewayOverviewIfConnected() + await MainActor.run { + self.updateHomeCanvasState() + } + } + } .onAppear { self.maybeShowQuickSetup() } .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onAppear { self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } - .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayStatusText) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } + .onChange(of: self.appModel.gatewayServerName) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.showOnboarding = false @@ -155,7 +173,13 @@ struct RootCanvas: View { .onChange(of: self.onboardingRequestID) { _, _ in self.evaluateOnboardingPresentation(force: true) } - .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in + self.updateCanvasDebugStatus() + self.updateHomeCanvasState() + } + .onChange(of: self.appModel.homeCanvasRevision) { _, _ in + self.updateHomeCanvasState() + } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.onboardingComplete = true @@ -209,6 +233,134 @@ struct RootCanvas: View { self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } + private func updateHomeCanvasState() { + let payload = self.makeHomeCanvasPayload() + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { + self.appModel.screen.updateHomeCanvasState(json: nil) + return + } + self.appModel.screen.updateHomeCanvasState(json: json) + } + + private func makeHomeCanvasPayload() -> HomeCanvasPayload { + let gatewayName = self.normalized(self.appModel.gatewayServerName) + let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress) + let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway" + let activeAgentID = self.resolveActiveAgentID() + let agents = self.homeCanvasAgents(activeAgentID: activeAgentID) + + switch self.gatewayStatus { + case .connected: + return HomeCanvasPayload( + gatewayState: "connected", + eyebrow: "Connected to \(gatewayLabel)", + title: "Your agents are ready", + subtitle: + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", + gatewayLabel: gatewayLabel, + activeAgentName: self.appModel.activeAgentName, + activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC", + activeAgentCaption: "Selected on this phone", + agentCount: agents.count, + agents: Array(agents.prefix(6)), + footer: "The overview refreshes on reconnect and when the app returns to foreground.") + case .connecting: + return HomeCanvasPayload( + gatewayState: "connecting", + eyebrow: "Reconnecting", + title: "OpenClaw is syncing back up", + subtitle: + "The gateway session is coming back online. " + + "Agent shortcuts should settle automatically in a moment.", + gatewayLabel: gatewayLabel, + activeAgentName: self.appModel.activeAgentName, + activeAgentBadge: "OC", + activeAgentCaption: "Gateway session in progress", + agentCount: agents.count, + agents: Array(agents.prefix(4)), + footer: "If the gateway is reachable, reconnect should complete without intervention.") + case .error, .disconnected: + return HomeCanvasPayload( + gatewayState: self.gatewayStatus == .error ? "error" : "offline", + eyebrow: "Welcome to OpenClaw", + title: "Your phone stays quiet until it is needed", + subtitle: + "Pair this device to your gateway to wake it only for real work, " + + "keep a live agent overview handy, and avoid battery-draining background loops.", + gatewayLabel: gatewayLabel, + activeAgentName: "Main", + activeAgentBadge: "OC", + activeAgentCaption: "Connect to load your agents", + agentCount: agents.count, + agents: Array(agents.prefix(4)), + footer: + "When connected, the gateway can wake the phone with a silent push " + + "instead of holding an always-on session.") + } + } + + private func resolveActiveAgentID() -> String { + let selected = self.normalized(self.appModel.selectedAgentId) ?? "" + if !selected.isEmpty { + return selected + } + return self.resolveDefaultAgentID() + } + + private func resolveDefaultAgentID() -> String { + self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" + } + + private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] { + let defaultAgentID = self.resolveDefaultAgentID() + let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in + let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID + let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID + return HomeCanvasAgentCard( + id: agent.id, + name: self.homeCanvasName(for: agent), + badge: self.homeCanvasBadge(for: agent), + caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"), + isActive: isActive) + } + + return cards.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { + return lhs.isActive + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private func homeCanvasName(for agent: AgentSummary) -> String { + self.normalized(agent.name) ?? agent.id + } + + private func homeCanvasBadge(for agent: AgentSummary) -> String { + if let identity = agent.identity, + let emoji = identity["emoji"]?.value as? String, + let normalizedEmoji = self.normalized(emoji) + { + return normalizedEmoji + } + let words = self.homeCanvasName(for: agent) + .split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) + .prefix(2) + let initials = words.compactMap { $0.first }.map(String.init).joined() + if !initials.isEmpty { + return initials.uppercased() + } + return "OC" + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func evaluateOnboardingPresentation(force: Bool) { if force { self.onboardingAllowSkip = true @@ -274,6 +426,28 @@ struct RootCanvas: View { } } +private struct HomeCanvasPayload: Codable { + var gatewayState: String + var eyebrow: String + var title: String + var subtitle: String + var gatewayLabel: String + var activeAgentName: String + var activeAgentBadge: String + var activeAgentCaption: String + var agentCount: Int + var agents: [HomeCanvasAgentCard] + var footer: String +} + +private struct HomeCanvasAgentCard: Codable { + var id: String + var name: String + var badge: String + var caption: String + var isActive: Bool +} + private struct CanvasContent: View { @Environment(NodeAppModel.self) private var appModel @AppStorage("talk.enabled") private var talkEnabled: Bool = false @@ -301,53 +475,33 @@ private struct CanvasContent: View { .transition(.opacity) } } - .overlay(alignment: .topLeading) { - HStack(alignment: .top, spacing: 8) { - StatusPill( - gateway: self.gatewayStatus, - voiceWakeEnabled: self.voiceWakeEnabled, - activity: self.statusActivity, - brighten: self.brightenButtons, - onTap: { - if self.gatewayStatus == .connected { - self.showGatewayActions = true - } else { - self.openSettings() - } - }) - .layoutPriority(1) - - Spacer(minLength: 8) - - HStack(spacing: 8) { - OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { - self.openChat() - } - .accessibilityLabel("Chat") - - if self.talkButtonEnabled { - // Keep Talk mode near status controls while freeing right-side screen real estate. - OverlayButton( - systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", - brighten: self.brightenButtons, - tint: self.appModel.seamColor, - isActive: self.talkActive) - { - let next = !self.talkActive - self.talkEnabled = next - self.appModel.setTalkEnabled(next) - } - .accessibilityLabel("Talk Mode") - } - - OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { + .safeAreaInset(edge: .bottom, spacing: 0) { + HomeToolbar( + gateway: self.gatewayStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, + brighten: self.brightenButtons, + talkButtonEnabled: self.talkButtonEnabled, + talkActive: self.talkActive, + talkTint: self.appModel.seamColor, + onStatusTap: { + if self.gatewayStatus == .connected { + self.showGatewayActions = true + } else { self.openSettings() } - .accessibilityLabel("Settings") - } - } - .padding(.horizontal, 10) - .safeAreaPadding(.top, 10) + }, + onChatTap: { + self.openChat() + }, + onTalkTap: { + let next = !self.talkActive + self.talkEnabled = next + self.appModel.setTalkEnabled(next) + }, + onSettingsTap: { + self.openSettings() + }) } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 5c945033551..4c9f3ff5085 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -20,6 +20,7 @@ final class ScreenController { private var debugStatusEnabled: Bool = false private var debugStatusTitle: String? private var debugStatusSubtitle: String? + private var homeCanvasStateJSON: String? init() { self.reload() @@ -94,6 +95,26 @@ final class ScreenController { subtitle: self.debugStatusSubtitle) } + func updateHomeCanvasState(json: String?) { + self.homeCanvasStateJSON = json + self.applyHomeCanvasStateIfNeeded() + } + + func applyHomeCanvasStateIfNeeded() { + guard let webView = self.activeWebView else { return } + let payload = self.homeCanvasStateJSON ?? "null" + let js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api || typeof api.renderHome !== 'function') return; + api.renderHome(\(payload)); + } catch (_) {} + })() + """ + webView.evaluateJavaScript(js) { _, _ in } + } + func waitForA2UIReady(timeoutMs: Int) async -> Bool { let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(timeoutMs)) @@ -191,6 +212,7 @@ final class ScreenController { self.activeWebView = webView self.reload() self.applyDebugStatusIfNeeded() + self.applyHomeCanvasStateIfNeeded() } func detachWebView(_ webView: WKWebView) { diff --git a/apps/ios/Sources/Screen/ScreenWebView.swift b/apps/ios/Sources/Screen/ScreenWebView.swift index a30d78cbd00..61f9af6515c 100644 --- a/apps/ios/Sources/Screen/ScreenWebView.swift +++ b/apps/ios/Sources/Screen/ScreenWebView.swift @@ -161,6 +161,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_: WKWebView, didFinish _: WKNavigation?) { self.controller?.errorText = nil self.controller?.applyDebugStatusIfNeeded() + self.controller?.applyHomeCanvasStateIfNeeded() } func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index a723ce5eb39..d6f94185b40 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -38,6 +38,7 @@ struct StatusPill: View { var gateway: GatewayState var voiceWakeEnabled: Bool var activity: Activity? + var compact: Bool = false var brighten: Bool = false var onTap: () -> Void @@ -45,11 +46,11 @@ struct StatusPill: View { var body: some View { Button(action: self.onTap) { - HStack(spacing: 10) { - HStack(spacing: 8) { + HStack(spacing: self.compact ? 8 : 10) { + HStack(spacing: self.compact ? 6 : 8) { Circle() .fill(self.gateway.color) - .frame(width: 9, height: 9) + .frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9) .scaleEffect( self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) @@ -58,34 +59,38 @@ struct StatusPill: View { .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) Text(self.gateway.title) - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(.primary) } - Divider() - .frame(height: 14) - .opacity(0.35) - if let activity { - HStack(spacing: 6) { + if !self.compact { + Divider() + .frame(height: 14) + .opacity(0.35) + } + + HStack(spacing: self.compact ? 4 : 6) { Image(systemName: activity.systemImage) - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(activity.tint ?? .primary) - Text(activity.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) + if !self.compact { + Text(activity.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } } .transition(.opacity.combined(with: .move(edge: .top))) } else { Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") - .font(.subheadline.weight(.semibold)) + .font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold)) .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") .transition(.opacity.combined(with: .move(edge: .top))) } } - .statusGlassCard(brighten: self.brighten, verticalPadding: 8) + .statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8) } .buttonStyle(.plain) .accessibilityLabel("Connection Status") diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html index ceb7a975da4..52ec996e90a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html @@ -3,7 +3,7 @@ - Canvas + OpenClaw - + +
+
+
+
+ + Welcome to OpenClaw +
+

Your phone stays quiet until it is needed

+

+ Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops. +

+ +
+
+
Gateway
+
Gateway
+
Connect to load your agents
+
+ +
+
Active Agent
+
+
OC
+
+
Main
+
Connect to load your agents
+
+
+
+
+
+ +
+
+
Live agents
+
0 agents
+
+
+ +
+
+
+
Ready
Waiting for agent
+ From 6bcf89b09bc9ddcfdf03ce81cb0ff61dfa95b52b Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Tue, 10 Mar 2026 20:52:40 +0200 Subject: [PATCH 17/31] feat(ios): refresh home canvas toolbar --- apps/ios/Sources/HomeToolbar.swift | 223 ++++++++++++++++++ apps/ios/Sources/Model/NodeAppModel.swift | 8 +- apps/ios/Sources/RootCanvas.swift | 57 ----- apps/ios/Sources/Screen/ScreenTab.swift | 2 +- apps/ios/Sources/Settings/SettingsTab.swift | 4 +- apps/ios/Tests/NodeAppModelInvokeTests.swift | 6 +- .../Resources/CanvasScaffold/scaffold.html | 91 ++++++- 7 files changed, 316 insertions(+), 75 deletions(-) create mode 100644 apps/ios/Sources/HomeToolbar.swift diff --git a/apps/ios/Sources/HomeToolbar.swift b/apps/ios/Sources/HomeToolbar.swift new file mode 100644 index 00000000000..924d95d7919 --- /dev/null +++ b/apps/ios/Sources/HomeToolbar.swift @@ -0,0 +1,223 @@ +import SwiftUI + +struct HomeToolbar: View { + var gateway: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var activity: StatusPill.Activity? + var brighten: Bool + var talkButtonEnabled: Bool + var talkActive: Bool + var talkTint: Color + var onStatusTap: () -> Void + var onChatTap: () -> Void + var onTalkTap: () -> Void + var onSettingsTap: () -> Void + + @Environment(\.colorSchemeContrast) private var contrast + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12))) + .frame(height: self.contrast == .increased ? 1.0 : 0.6) + .allowsHitTesting(false) + + HStack(spacing: 12) { + HomeToolbarStatusButton( + gateway: self.gateway, + voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.activity, + brighten: self.brighten, + onTap: self.onStatusTap) + + Spacer(minLength: 0) + + HStack(spacing: 8) { + HomeToolbarActionButton( + systemImage: "text.bubble.fill", + accessibilityLabel: "Chat", + brighten: self.brighten, + action: self.onChatTap) + + if self.talkButtonEnabled { + HomeToolbarActionButton( + systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle", + accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off", + brighten: self.brighten, + tint: self.talkTint, + isActive: self.talkActive, + action: self.onTalkTap) + } + + HomeToolbarActionButton( + systemImage: "gearshape.fill", + accessibilityLabel: "Settings", + brighten: self.brighten, + action: self.onSettingsTap) + } + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + .overlay(alignment: .top) { + LinearGradient( + colors: [ + .white.opacity(self.brighten ? 0.10 : 0.06), + .clear, + ], + startPoint: .top, + endPoint: .bottom) + .allowsHitTesting(false) + } + } +} + +private struct HomeToolbarStatusButton: View { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + + var gateway: StatusPill.GatewayState + var voiceWakeEnabled: Bool + var activity: StatusPill.Activity? + var brighten: Bool + var onTap: () -> Void + + @State private var pulse: Bool = false + + var body: some View { + Button(action: self.onTap) { + HStack(spacing: 8) { + HStack(spacing: 6) { + Circle() + .fill(self.gateway.color) + .frame(width: 8, height: 8) + .scaleEffect( + self.gateway == .connecting && !self.reduceMotion + ? (self.pulse ? 1.15 : 0.85) + : 1.0 + ) + .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) + + Text(self.gateway.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + + if let activity { + Image(systemName: activity.systemImage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(activity.tint ?? .primary) + .transition(.opacity.combined(with: .move(edge: .top))) + } else { + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.footnote.weight(.semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.black.opacity(self.brighten ? 0.12 : 0.18)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)), + lineWidth: self.contrast == .increased ? 1.0 : 0.6) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Connection Status") + .accessibilityValue(self.accessibilityValue) + .accessibilityHint(self.gateway == .connected ? "Double tap for gateway actions" : "Double tap to open settings") + .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } + .onDisappear { self.pulse = false } + .onChange(of: self.gateway) { _, newValue in + self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) + } + .onChange(of: self.scenePhase) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion) + } + .onChange(of: self.reduceMotion) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue) + } + .animation(.easeInOut(duration: 0.18), value: self.activity?.title) + } + + private var accessibilityValue: String { + if let activity { + return "\(self.gateway.title), \(activity.title)" + } + return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" + } + + private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) { + guard gateway == .connecting, scenePhase == .active, !reduceMotion else { + withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false } + return + } + + guard !self.pulse else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + self.pulse = true + } + } +} + +private struct HomeToolbarActionButton: View { + @Environment(\.colorSchemeContrast) private var contrast + + let systemImage: String + let accessibilityLabel: String + let brighten: Bool + var tint: Color? + var isActive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: self.action) { + Image(systemName: self.systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) + .frame(width: 40, height: 40) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(self.brighten ? 0.12 : 0.18)) + .overlay { + if let tint { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill( + LinearGradient( + colors: [ + tint.opacity(self.isActive ? 0.22 : 0.14), + tint.opacity(self.isActive ? 0.08 : 0.04), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .blendMode(.overlay) + } + } + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + (self.tint ?? .white).opacity( + self.isActive + ? 0.34 + : (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16)) + ), + lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6)) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(self.accessibilityLabel) + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index bbb79d98db0..babb6b449da 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1639,11 +1639,9 @@ extension NodeAppModel { } var chatSessionKey: String { - let base = "ios" - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } - return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + // Keep chat aligned with the gateway's resolved main session key. + // A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI. + self.mainSessionKey } var activeAgentName: String { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 64e3d739b96..3a078f271c4 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -534,63 +534,6 @@ private struct CanvasContent: View { } } -private struct OverlayButton: View { - let systemImage: String - let brighten: Bool - var tint: Color? - var isActive: Bool = false - let action: () -> Void - - var body: some View { - Button(action: self.action) { - Image(systemName: self.systemImage) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) - .padding(10) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - .white.opacity(self.brighten ? 0.26 : 0.18), - .white.opacity(self.brighten ? 0.08 : 0.04), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - .overlay { - if let tint { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill( - LinearGradient( - colors: [ - tint.opacity(self.isActive ? 0.22 : 0.14), - tint.opacity(self.isActive ? 0.10 : 0.06), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .blendMode(.overlay) - } - } - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - (self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)), - lineWidth: self.isActive ? 0.7 : 0.5) - } - .shadow(color: .black.opacity(0.35), radius: 12, y: 6) - } - } - .buttonStyle(.plain) - } -} - private struct CameraFlashOverlay: View { var nonce: Int diff --git a/apps/ios/Sources/Screen/ScreenTab.swift b/apps/ios/Sources/Screen/ScreenTab.swift index 16b5f857496..deabd38331d 100644 --- a/apps/ios/Sources/Screen/ScreenTab.swift +++ b/apps/ios/Sources/Screen/ScreenTab.swift @@ -7,7 +7,7 @@ struct ScreenTab: View { var body: some View { ZStack(alignment: .top) { ScreenWebView(controller: self.appModel.screen) - .ignoresSafeArea() + .ignoresSafeArea(.container, edges: [.top, .leading, .trailing]) .overlay(alignment: .top) { if let errorText = self.appModel.screen.errorText, self.appModel.gatewayServerName == nil diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 7186c7205b5..a48bc82ae86 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -340,9 +340,9 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } self.featureToggle( - "Show Talk Button", + "Show Talk Control", isOn: self.$talkButtonEnabled, - help: "Shows the floating Talk button in the main interface.") + help: "Shows the Talk control in the main toolbar.") TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) .lineLimit(2 ... 6) .textInputAutocapitalization(.sentences) diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 7413b0295f9..d2ec7039ad7 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -83,16 +83,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(json.contains("\"value\"")) } - @Test @MainActor func chatSessionKeyDefaultsToIOSBase() { + @Test @MainActor func chatSessionKeyDefaultsToMainBase() { let appModel = NodeAppModel() - #expect(appModel.chatSessionKey == "ios") + #expect(appModel.chatSessionKey == "main") } @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { let appModel = NodeAppModel() appModel.gatewayDefaultAgentId = "main" appModel.setSelectedAgentId("agent-123") - #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios")) + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "main")) #expect(appModel.mainSessionKey == "agent:agent-123:main") } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html index 52ec996e90a..684d5a9f148 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasScaffold/scaffold.html @@ -113,18 +113,23 @@ inset: 0; z-index: 2; display: flex; - align-items: stretch; + align-items: flex-start; justify-content: center; - padding: calc(var(--safe-top) + 22px) 18px calc(var(--safe-bottom) + 18px); + padding: calc(var(--safe-top) + 18px) 16px calc(var(--safe-bottom) + 18px); box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; } .shell { width: min(100%, 760px); display: flex; flex-direction: column; - justify-content: center; - gap: 18px; + justify-content: flex-start; + gap: 16px; + min-height: 100%; + box-sizing: border-box; } .hero { @@ -161,9 +166,12 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + max-width: 100%; + box-sizing: border-box; } .eyebrow-dot { + flex: 0 0 auto; width: 9px; height: 9px; border-radius: 999px; @@ -171,6 +179,13 @@ box-shadow: 0 0 18px color-mix(in srgb, var(--state) 68%, transparent); } + #openclaw-home-eyebrow { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .hero h1 { margin: 18px 0 0; font-size: clamp(32px, 7vw, 52px); @@ -218,6 +233,7 @@ font-size: 24px; font-weight: 700; letter-spacing: -0.03em; + overflow-wrap: anywhere; } .meta-subtitle { @@ -229,8 +245,9 @@ .agent-focus { display: flex; - align-items: center; + align-items: flex-start; gap: 14px; + margin-top: 8px; } .agent-badge { @@ -252,6 +269,7 @@ font-size: 22px; font-weight: 700; letter-spacing: -0.03em; + overflow-wrap: anywhere; } .agent-focus .caption { @@ -323,6 +341,7 @@ font-size: 15px; font-weight: 700; line-height: 1.2; + overflow-wrap: anywhere; } .agent-row .caption { @@ -384,8 +403,8 @@ @media (max-width: 640px) { #openclaw-home { - padding-left: 14px; - padding-right: 14px; + padding-left: 12px; + padding-right: 12px; } .hero { @@ -403,6 +422,64 @@ } } + @media (max-height: 760px) { + #openclaw-home { + padding-top: calc(var(--safe-top) + 14px); + padding-bottom: calc(var(--safe-bottom) + 12px); + } + + .shell { + gap: 12px; + } + + .hero { + border-radius: 24px; + padding: 16px 15px 15px; + } + + .hero h1 { + margin-top: 14px; + font-size: clamp(28px, 8vw, 38px); + } + + .hero p { + margin-top: 10px; + font-size: 15px; + line-height: 1.42; + } + + .hero-grid { + margin-top: 18px; + } + + .meta-card { + padding: 14px 14px 13px; + } + + .meta-value { + font-size: 22px; + } + + .agent-badge { + width: 50px; + height: 50px; + border-radius: 16px; + font-size: 22px; + } + + .agent-focus .name { + font-size: 20px; + } + + .section { + padding: 14px 14px 12px; + } + + .section-header { + margin-bottom: 10px; + } + } + @media (prefers-reduced-motion: reduce) { body::before, body::after { From c2e41c57c9d2a92919318d220adad8cbf5e0dd45 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Tue, 10 Mar 2026 21:27:22 +0200 Subject: [PATCH 18/31] fix(ios): make pairing instructions generic --- apps/ios/Sources/Onboarding/OnboardingWizardView.swift | 2 +- apps/ios/Sources/Settings/SettingsTab.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 8a97b20e0c7..4cefeb77e74 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -536,7 +536,7 @@ struct OnboardingWizardView: View { Text( "Approve this device on the gateway.\n" + "1) `openclaw devices approve` (or `openclaw devices approve `)\n" - + "2) `/pair approve` in Telegram\n" + + "2) `/pair approve` in your OpenClaw chat\n" + "\(requestLine)\n" + "OpenClaw will also retry automatically when you return to this app.") } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index a48bc82ae86..7aa79fa24ca 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -65,10 +65,10 @@ struct SettingsTab: View { DisclosureGroup(isExpanded: self.$gatewayExpanded) { if !self.isGatewayConnected { Text( - "1. Open Telegram and message your bot: /pair\n" + "1. Open a chat with your OpenClaw agent and send /pair\n" + "2. Copy the setup code it returns\n" + "3. Paste here and tap Connect\n" - + "4. Back in Telegram, run /pair approve") + + "4. Back in that chat, run /pair approve") .font(.footnote) .foregroundStyle(.secondary) @@ -896,7 +896,7 @@ struct SettingsTab: View { guard !trimmed.isEmpty else { return nil } let lower = trimmed.lowercased() if lower.contains("pairing required") { - return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again." + return "Pairing required. Go back to your OpenClaw chat and run /pair approve, then tap Connect again." } if lower.contains("device nonce required") || lower.contains("device nonce mismatch") { return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again." From 77a35025e86d0d07304a2e2e74e378b31c1f1e27 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 9 Mar 2026 13:26:17 +0800 Subject: [PATCH 19/31] feat: integrate Alibaba Bailian Coding Plan into onboarding wizard --- src/cli/program/register.onboard.ts | 2 + src/commands/auth-choice-options.ts | 17 +++ .../auth-choice.apply.api-providers.ts | 46 ++++++++ src/commands/onboard-auth.config-core.ts | 98 +++++++++++++++++ src/commands/onboard-auth.credentials.ts | 18 ++- src/commands/onboard-auth.models.ts | 103 ++++++++++++++++++ src/commands/onboard-auth.ts | 6 + .../local/auth-choice-inference.ts | 2 + .../local/auth-choice.ts | 57 ++++++++++ src/commands/onboard-provider-auth-flags.ts | 16 +++ src/commands/onboard-types.ts | 5 + 11 files changed, 369 insertions(+), 1 deletion(-) diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 03fb832a041..7fd5283dcd4 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -160,6 +160,8 @@ export function registerOnboardCommand(program: Command) { zaiApiKey: opts.zaiApiKey as string | undefined, xiaomiApiKey: opts.xiaomiApiKey as string | undefined, qianfanApiKey: opts.qianfanApiKey as string | undefined, + bailianApiKeyCn: opts.bailianApiKeyCn as string | undefined, + bailianApiKey: opts.bailianApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined, veniceApiKey: opts.veniceApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 27fee5dc01f..3c7a609161e 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -119,6 +119,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["qianfan-api-key"], }, + { + value: "bailian", + label: "Alibaba Bailian", + hint: "Coding Plan API key (CN / Global)", + choices: ["bailian-api-key-cn", "bailian-api-key"], + }, { value: "copilot", label: "Copilot", @@ -297,6 +303,17 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ label: "MiniMax M2.5 Highspeed", hint: "Official fast tier", }, + { value: "qianfan-api-key", label: "Qianfan API key" }, + { + value: "bailian-api-key-cn", + label: "Coding Plan API Key for China (subscription)", + hint: "Endpoint: coding.dashscope.aliyuncs.com", + }, + { + value: "bailian-api-key", + label: "Coding Plan API Key for Global/Intl (subscription)", + hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", + }, { value: "custom-api-key", label: "Custom Provider" }, ]; diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 370951e9f0d..9d7b717385b 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -76,6 +76,12 @@ import { setXiaomiApiKey, setZaiApiKey, ZAI_DEFAULT_MODEL_REF, + BAILIAN_DEFAULT_MODEL_REF, + applyBailianConfig, + applyBailianConfigCn, + applyBailianProviderConfig, + applyBailianProviderConfigCn, + setBailianApiKey, } from "./onboard-auth.js"; import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; @@ -295,6 +301,46 @@ const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial String(value ?? "").trim(), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }, + "bailian-api-key": { + provider: "bailian", + profileId: "bailian:default", + expectedProviders: ["bailian"], + envLabel: "BAILIAN_API_KEY", + promptMessage: "Enter Alibaba Bailian Coding Plan API key (Global/Intl)", + setCredential: setBailianApiKey, + defaultModel: BAILIAN_DEFAULT_MODEL_REF, + applyDefaultConfig: applyBailianConfig, + applyProviderConfig: applyBailianProviderConfig, + noteDefault: BAILIAN_DEFAULT_MODEL_REF, + noteMessage: [ + "Get your API key at: https://bailian.console.aliyun.com/", + "Endpoint: coding-intl.dashscope.aliyuncs.com", + "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", + ].join("\n"), + noteTitle: "Alibaba Bailian Coding Plan (Global/Intl)", + normalize: (value) => String(value ?? "").trim(), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }, "synthetic-api-key": { provider: "synthetic", profileId: "synthetic:default", diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 103343d5914..5b6e0d5a590 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -573,3 +573,101 @@ export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { const next = applyQianfanProviderConfig(cfg); return applyAgentDefaultModelPrimary(next, QIANFAN_DEFAULT_MODEL_REF); } + +// Alibaba Cloud Model Studio (Bailian) Coding Plan +import { + BAILIAN_CN_BASE_URL, + BAILIAN_GLOBAL_BASE_URL, + BAILIAN_DEFAULT_MODEL_REF, + buildBailianModelDefinition, +} from "./onboard-auth.models.js"; + +function applyBailianProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + baseUrl: string, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + + const bailianModelIds = [ + "qwen3.5-plus", + "qwen3-max-2026-01-23", + "qwen3-coder-next", + "qwen3-coder-plus", + "MiniMax-M2.5", + "glm-5", + "glm-4.7", + "kimi-k2.5", + ]; + for (const modelId of bailianModelIds) { + const modelRef = `bailian/${modelId}`; + if (!models[modelRef]) { + models[modelRef] = {}; + } + } + models[BAILIAN_DEFAULT_MODEL_REF] = { + ...models[BAILIAN_DEFAULT_MODEL_REF], + alias: models[BAILIAN_DEFAULT_MODEL_REF]?.alias ?? "Qwen", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.bailian; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + + const defaultModels = [ + buildBailianModelDefinition({ id: "qwen3.5-plus" }), + buildBailianModelDefinition({ id: "qwen3-max-2026-01-23" }), + buildBailianModelDefinition({ id: "qwen3-coder-next" }), + buildBailianModelDefinition({ id: "qwen3-coder-plus" }), + buildBailianModelDefinition({ id: "MiniMax-M2.5" }), + buildBailianModelDefinition({ id: "glm-5" }), + buildBailianModelDefinition({ id: "glm-4.7" }), + buildBailianModelDefinition({ id: "kimi-k2.5" }), + ]; + + const mergedModels = [...existingModels]; + const seen = new Set(existingModels.map((m) => m.id)); + for (const model of defaultModels) { + if (!seen.has(model.id)) { + mergedModels.push(model); + seen.add(model.id); + } + } + + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + + providers.bailian = { + ...existingProviderRest, + baseUrl, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : defaultModels, + }; + + return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); +} + +export function applyBailianProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const existingBaseUrl = cfg.models?.providers?.bailian?.baseUrl; + const resolvedBaseUrl = + typeof existingBaseUrl === "string" ? existingBaseUrl : BAILIAN_GLOBAL_BASE_URL; + return applyBailianProviderConfigWithBaseUrl(cfg, resolvedBaseUrl); +} + +export function applyBailianProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyBailianProviderConfigWithBaseUrl(cfg, BAILIAN_CN_BASE_URL); +} + +export function applyBailianConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyBailianProviderConfig(cfg); + return applyAgentDefaultModelPrimary(next, BAILIAN_DEFAULT_MODEL_REF); +} + +export function applyBailianConfigCn(cfg: OpenClawConfig): OpenClawConfig { + const next = applyBailianProviderConfigCn(cfg); + return applyAgentDefaultModelPrimary(next, BAILIAN_DEFAULT_MODEL_REF); +} diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index c32a3ea9ae6..41b95f0d537 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -15,7 +15,11 @@ import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import type { SecretInputMode } from "./onboard-types.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; -export { MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js"; +export { + MISTRAL_DEFAULT_MODEL_REF, + XAI_DEFAULT_MODEL_REF, + BAILIAN_DEFAULT_MODEL_REF, +} from "./onboard-auth.models.js"; export { KILOCODE_DEFAULT_MODEL_REF }; const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); @@ -472,6 +476,18 @@ export function setQianfanApiKey( }); } +export function setBailianApiKey( + key: SecretInput, + agentDir?: string, + options?: ApiKeyStorageOptions, +) { + upsertAuthProfile({ + profileId: "bailian:default", + credential: buildApiKeyCredential("bailian", key, undefined, options), + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export function setXaiApiKey(key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions) { upsertAuthProfile({ profileId: "xai:default", diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 36ae85dadac..d3615bdd33e 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -224,3 +224,106 @@ export function buildKilocodeModelDefinition(): ModelDefinitionConfig { maxTokens: KILOCODE_DEFAULT_MAX_TOKENS, }; } + +// Alibaba Cloud Model Studio (Bailian) Coding Plan +export const BAILIAN_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; +export const BAILIAN_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const BAILIAN_BASE_URL = BAILIAN_CN_BASE_URL; +export const BAILIAN_DEFAULT_MODEL_ID = "qwen3.5-plus"; +export const BAILIAN_DEFAULT_MODEL_REF = `bailian/${BAILIAN_DEFAULT_MODEL_ID}`; +export const BAILIAN_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const BAILIAN_MODEL_CATALOG = { + "qwen3.5-plus": { + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "qwen3-max-2026-01-23": { + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-next": { + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-plus": { + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "MiniMax-M2.5": { + name: "MiniMax-M2.5", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "glm-5": { + name: "glm-5", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "glm-4.7": { + name: "glm-4.7", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "kimi-k2.5": { + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 32768, + }, +} as const; + +type BailianCatalogId = keyof typeof BAILIAN_MODEL_CATALOG; + +export function buildBailianModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + input?: string[]; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = BAILIAN_MODEL_CATALOG[params.id as BailianCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? params.id, + reasoning: params.reasoning ?? catalog?.reasoning ?? false, + input: + (params.input as ("text" | "image")[]) ?? + ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), + cost: params.cost ?? BAILIAN_DEFAULT_COST, + contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, + maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, + }; +} + +export function buildBailianDefaultModelDefinition(): ModelDefinitionConfig { + return buildBailianModelDefinition({ + id: BAILIAN_DEFAULT_MODEL_ID, + }); +} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 13d2cf75bf0..8c81a0fa962 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -39,6 +39,10 @@ export { applyXiaomiProviderConfig, applyZaiConfig, applyZaiProviderConfig, + applyBailianConfig, + applyBailianConfigCn, + applyBailianProviderConfig, + applyBailianProviderConfigCn, KILOCODE_BASE_URL, } from "./onboard-auth.config-core.js"; export { @@ -84,6 +88,7 @@ export { setVolcengineApiKey, setZaiApiKey, setXaiApiKey, + setBailianApiKey, writeOAuthCredentials, HUGGINGFACE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, @@ -92,6 +97,7 @@ export { TOGETHER_DEFAULT_MODEL_REF, MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF, + BAILIAN_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { buildKilocodeModelDefinition, diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index aecab3ba489..29aeaa1d4e9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -30,6 +30,8 @@ type AuthChoiceFlagOptions = Pick< | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" + | "bailianApiKeyCn" + | "bailianApiKey" | "volcengineApiKey" | "byteplusApiKey" | "customBaseUrl" diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 98eef51dd20..30ecc9c20ac 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -15,6 +15,8 @@ import { applyCloudflareAiGatewayConfig, applyKilocodeConfig, applyQianfanConfig, + applyBailianConfig, + applyBailianConfigCn, applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxApiConfigCn, @@ -37,6 +39,7 @@ import { setCloudflareAiGatewayConfig, setByteplusApiKey, setQianfanApiKey, + setBailianApiKey, setGeminiApiKey, setKilocodeApiKey, setKimiCodingApiKey, @@ -498,6 +501,60 @@ export async function applyNonInteractiveAuthChoice(params: { return applyQianfanConfig(nextConfig); } + if (authChoice === "bailian-api-key-cn") { + const resolved = await resolveApiKey({ + provider: "bailian", + cfg: baseConfig, + flagValue: opts.bailianApiKeyCn, + flagName: "--bailian-api-key-cn", + envVar: "BAILIAN_API_KEY", + runtime, + }); + if (!resolved) { + return null; + } + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setBailianApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "bailian:default", + provider: "bailian", + mode: "api_key", + }); + return applyBailianConfigCn(nextConfig); + } + + if (authChoice === "bailian-api-key") { + const resolved = await resolveApiKey({ + provider: "bailian", + cfg: baseConfig, + flagValue: opts.bailianApiKey, + flagName: "--bailian-api-key", + envVar: "BAILIAN_API_KEY", + runtime, + }); + if (!resolved) { + return null; + } + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setBailianApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; + } + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "bailian:default", + provider: "bailian", + mode: "api_key", + }); + return applyBailianConfig(nextConfig); + } + if (authChoice === "openai-api-key") { const resolved = await resolveApiKey({ provider: "openai", diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index a1038625a78..6555caf1f91 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -23,6 +23,8 @@ type OnboardProviderAuthOptionKey = keyof Pick< | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" + | "bailianApiKeyCn" + | "bailianApiKey" | "volcengineApiKey" | "byteplusApiKey" >; @@ -184,6 +186,20 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray cliOption: "--qianfan-api-key ", description: "QIANFAN API key", }, + { + optionKey: "bailianApiKeyCn", + authChoice: "bailian-api-key-cn", + cliFlag: "--bailian-api-key-cn", + cliOption: "--bailian-api-key-cn ", + description: "Alibaba Bailian Coding Plan API key (China)", + }, + { + optionKey: "bailianApiKey", + authChoice: "bailian-api-key", + cliFlag: "--bailian-api-key", + cliOption: "--bailian-api-key ", + description: "Alibaba Bailian Coding Plan API key (Global/Intl)", + }, { optionKey: "volcengineApiKey", authChoice: "volcengine-api-key", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 7e938430517..e6d1bbcf806 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -49,6 +49,8 @@ export type AuthChoice = | "volcengine-api-key" | "byteplus-api-key" | "qianfan-api-key" + | "bailian-api-key-cn" + | "bailian-api-key" | "custom-api-key" | "skip"; export type AuthChoiceGroupId = @@ -75,6 +77,7 @@ export type AuthChoiceGroupId = | "together" | "huggingface" | "qianfan" + | "bailian" | "xai" | "volcengine" | "byteplus" @@ -135,6 +138,8 @@ export type OnboardOptions = { volcengineApiKey?: string; byteplusApiKey?: string; qianfanApiKey?: string; + bailianApiKeyCn?: string; + bailianApiKey?: string; customBaseUrl?: string; customApiKey?: string; customModelId?: string; From 95eaa087811ce691adc5624170b26e4fac2f5734 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 9 Mar 2026 13:56:32 +0800 Subject: [PATCH 20/31] refactor: rename bailian to modelstudio and fix review issues - Rename provider ID, constants, functions, CLI flags, and types from "bailian" to "modelstudio" to match the official English name "Alibaba Cloud Model Studio". - Fix P2 bug: global endpoint variant now always overwrites baseUrl instead of silently preserving a stale CN URL. - Fix P1 bug: add modelstudio entry to PROVIDER_ENV_VARS so secret-input-mode=ref no longer throws. - Move Model Studio imports to top of onboard-auth.config-core.ts. - Remove unused BAILIAN_BASE_URL export. Made-with: Cursor --- src/cli/program/register.onboard.ts | 4 +- src/commands/auth-choice-options.ts | 10 +-- .../auth-choice.apply.api-providers.ts | 60 ++++++++-------- src/commands/onboard-auth.config-core.ts | 69 +++++++++---------- src/commands/onboard-auth.credentials.ts | 8 +-- src/commands/onboard-auth.models.ts | 29 ++++---- src/commands/onboard-auth.ts | 12 ++-- .../local/auth-choice-inference.ts | 4 +- .../local/auth-choice.ts | 42 +++++------ src/commands/onboard-provider-auth-flags.ts | 24 +++---- src/commands/onboard-types.ts | 10 +-- src/secrets/provider-env-vars.ts | 1 + 12 files changed, 134 insertions(+), 139 deletions(-) diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 7fd5283dcd4..6a5bd98aea0 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -160,8 +160,8 @@ export function registerOnboardCommand(program: Command) { zaiApiKey: opts.zaiApiKey as string | undefined, xiaomiApiKey: opts.xiaomiApiKey as string | undefined, qianfanApiKey: opts.qianfanApiKey as string | undefined, - bailianApiKeyCn: opts.bailianApiKeyCn as string | undefined, - bailianApiKey: opts.bailianApiKey as string | undefined, + modelstudioApiKeyCn: opts.modelstudioApiKeyCn as string | undefined, + modelstudioApiKey: opts.modelstudioApiKey as string | undefined, minimaxApiKey: opts.minimaxApiKey as string | undefined, syntheticApiKey: opts.syntheticApiKey as string | undefined, veniceApiKey: opts.veniceApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 3c7a609161e..23e9b80d958 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -120,10 +120,10 @@ const AUTH_CHOICE_GROUP_DEFS: { choices: ["qianfan-api-key"], }, { - value: "bailian", - label: "Alibaba Bailian", + value: "modelstudio", + label: "Alibaba Cloud Model Studio", hint: "Coding Plan API key (CN / Global)", - choices: ["bailian-api-key-cn", "bailian-api-key"], + choices: ["modelstudio-api-key-cn", "modelstudio-api-key"], }, { value: "copilot", @@ -305,12 +305,12 @@ const BASE_AUTH_CHOICE_OPTIONS: ReadonlyArray = [ }, { value: "qianfan-api-key", label: "Qianfan API key" }, { - value: "bailian-api-key-cn", + value: "modelstudio-api-key-cn", label: "Coding Plan API Key for China (subscription)", hint: "Endpoint: coding.dashscope.aliyuncs.com", }, { - value: "bailian-api-key", + value: "modelstudio-api-key", label: "Coding Plan API Key for Global/Intl (subscription)", hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", }, diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 9d7b717385b..046a2e24893 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -76,12 +76,12 @@ import { setXiaomiApiKey, setZaiApiKey, ZAI_DEFAULT_MODEL_REF, - BAILIAN_DEFAULT_MODEL_REF, - applyBailianConfig, - applyBailianConfigCn, - applyBailianProviderConfig, - applyBailianProviderConfigCn, - setBailianApiKey, + MODELSTUDIO_DEFAULT_MODEL_REF, + applyModelStudioConfig, + applyModelStudioConfigCn, + applyModelStudioProviderConfig, + applyModelStudioProviderConfigCn, + setModelStudioApiKey, } from "./onboard-auth.js"; import type { AuthChoice, SecretInputMode } from "./onboard-types.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; @@ -301,43 +301,43 @@ const SIMPLE_API_KEY_PROVIDER_FLOWS: Partial String(value ?? "").trim(), validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }, - "bailian-api-key": { - provider: "bailian", - profileId: "bailian:default", - expectedProviders: ["bailian"], - envLabel: "BAILIAN_API_KEY", - promptMessage: "Enter Alibaba Bailian Coding Plan API key (Global/Intl)", - setCredential: setBailianApiKey, - defaultModel: BAILIAN_DEFAULT_MODEL_REF, - applyDefaultConfig: applyBailianConfig, - applyProviderConfig: applyBailianProviderConfig, - noteDefault: BAILIAN_DEFAULT_MODEL_REF, + "modelstudio-api-key": { + provider: "modelstudio", + profileId: "modelstudio:default", + expectedProviders: ["modelstudio"], + envLabel: "MODELSTUDIO_API_KEY", + promptMessage: "Enter Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", + setCredential: setModelStudioApiKey, + defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF, + applyDefaultConfig: applyModelStudioConfig, + applyProviderConfig: applyModelStudioProviderConfig, + noteDefault: MODELSTUDIO_DEFAULT_MODEL_REF, noteMessage: [ "Get your API key at: https://bailian.console.aliyun.com/", "Endpoint: coding-intl.dashscope.aliyuncs.com", "Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.", ].join("\n"), - noteTitle: "Alibaba Bailian Coding Plan (Global/Intl)", + noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)", normalize: (value) => String(value ?? "").trim(), validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }, diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index 5b6e0d5a590..4bda29df1bf 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -65,6 +65,7 @@ import { buildZaiModelDefinition, buildMoonshotModelDefinition, buildXaiModelDefinition, + buildModelStudioModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_MODEL_ID, QIANFAN_BASE_URL, @@ -79,6 +80,9 @@ import { resolveZaiBaseUrl, XAI_BASE_URL, XAI_DEFAULT_MODEL_ID, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_GLOBAL_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.models.js"; export function applyZaiProviderConfig( @@ -574,21 +578,15 @@ export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { return applyAgentDefaultModelPrimary(next, QIANFAN_DEFAULT_MODEL_REF); } -// Alibaba Cloud Model Studio (Bailian) Coding Plan -import { - BAILIAN_CN_BASE_URL, - BAILIAN_GLOBAL_BASE_URL, - BAILIAN_DEFAULT_MODEL_REF, - buildBailianModelDefinition, -} from "./onboard-auth.models.js"; +// Alibaba Cloud Model Studio Coding Plan -function applyBailianProviderConfigWithBaseUrl( +function applyModelStudioProviderConfigWithBaseUrl( cfg: OpenClawConfig, baseUrl: string, ): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; - const bailianModelIds = [ + const modelStudioModelIds = [ "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", @@ -598,30 +596,30 @@ function applyBailianProviderConfigWithBaseUrl( "glm-4.7", "kimi-k2.5", ]; - for (const modelId of bailianModelIds) { - const modelRef = `bailian/${modelId}`; + for (const modelId of modelStudioModelIds) { + const modelRef = `modelstudio/${modelId}`; if (!models[modelRef]) { models[modelRef] = {}; } } - models[BAILIAN_DEFAULT_MODEL_REF] = { - ...models[BAILIAN_DEFAULT_MODEL_REF], - alias: models[BAILIAN_DEFAULT_MODEL_REF]?.alias ?? "Qwen", + models[MODELSTUDIO_DEFAULT_MODEL_REF] = { + ...models[MODELSTUDIO_DEFAULT_MODEL_REF], + alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen", }; const providers = { ...cfg.models?.providers }; - const existingProvider = providers.bailian; + const existingProvider = providers.modelstudio; const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; const defaultModels = [ - buildBailianModelDefinition({ id: "qwen3.5-plus" }), - buildBailianModelDefinition({ id: "qwen3-max-2026-01-23" }), - buildBailianModelDefinition({ id: "qwen3-coder-next" }), - buildBailianModelDefinition({ id: "qwen3-coder-plus" }), - buildBailianModelDefinition({ id: "MiniMax-M2.5" }), - buildBailianModelDefinition({ id: "glm-5" }), - buildBailianModelDefinition({ id: "glm-4.7" }), - buildBailianModelDefinition({ id: "kimi-k2.5" }), + buildModelStudioModelDefinition({ id: "qwen3.5-plus" }), + buildModelStudioModelDefinition({ id: "qwen3-max-2026-01-23" }), + buildModelStudioModelDefinition({ id: "qwen3-coder-next" }), + buildModelStudioModelDefinition({ id: "qwen3-coder-plus" }), + buildModelStudioModelDefinition({ id: "MiniMax-M2.5" }), + buildModelStudioModelDefinition({ id: "glm-5" }), + buildModelStudioModelDefinition({ id: "glm-4.7" }), + buildModelStudioModelDefinition({ id: "kimi-k2.5" }), ]; const mergedModels = [...existingModels]; @@ -640,7 +638,7 @@ function applyBailianProviderConfigWithBaseUrl( const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; const normalizedApiKey = resolvedApiKey?.trim(); - providers.bailian = { + providers.modelstudio = { ...existingProviderRest, baseUrl, api: "openai-completions", @@ -651,23 +649,20 @@ function applyBailianProviderConfigWithBaseUrl( return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); } -export function applyBailianProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const existingBaseUrl = cfg.models?.providers?.bailian?.baseUrl; - const resolvedBaseUrl = - typeof existingBaseUrl === "string" ? existingBaseUrl : BAILIAN_GLOBAL_BASE_URL; - return applyBailianProviderConfigWithBaseUrl(cfg, resolvedBaseUrl); +export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL); } -export function applyBailianProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { - return applyBailianProviderConfigWithBaseUrl(cfg, BAILIAN_CN_BASE_URL); +export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL); } -export function applyBailianConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyBailianProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, BAILIAN_DEFAULT_MODEL_REF); +export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyModelStudioProviderConfig(cfg); + return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); } -export function applyBailianConfigCn(cfg: OpenClawConfig): OpenClawConfig { - const next = applyBailianProviderConfigCn(cfg); - return applyAgentDefaultModelPrimary(next, BAILIAN_DEFAULT_MODEL_REF); +export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig { + const next = applyModelStudioProviderConfigCn(cfg); + return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); } diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 41b95f0d537..c83861b5685 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -18,7 +18,7 @@ export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai export { MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF, - BAILIAN_DEFAULT_MODEL_REF, + MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.models.js"; export { KILOCODE_DEFAULT_MODEL_REF }; @@ -476,14 +476,14 @@ export function setQianfanApiKey( }); } -export function setBailianApiKey( +export function setModelStudioApiKey( key: SecretInput, agentDir?: string, options?: ApiKeyStorageOptions, ) { upsertAuthProfile({ - profileId: "bailian:default", - credential: buildApiKeyCredential("bailian", key, undefined, options), + profileId: "modelstudio:default", + credential: buildApiKeyCredential("modelstudio", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index d3615bdd33e..2945e7b4461 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -225,20 +225,19 @@ export function buildKilocodeModelDefinition(): ModelDefinitionConfig { }; } -// Alibaba Cloud Model Studio (Bailian) Coding Plan -export const BAILIAN_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; -export const BAILIAN_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; -export const BAILIAN_BASE_URL = BAILIAN_CN_BASE_URL; -export const BAILIAN_DEFAULT_MODEL_ID = "qwen3.5-plus"; -export const BAILIAN_DEFAULT_MODEL_REF = `bailian/${BAILIAN_DEFAULT_MODEL_ID}`; -export const BAILIAN_DEFAULT_COST = { +// Alibaba Cloud Model Studio Coding Plan +export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; +export const MODELSTUDIO_DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }; -const BAILIAN_MODEL_CATALOG = { +const MODELSTUDIO_MODEL_CATALOG = { "qwen3.5-plus": { name: "qwen3.5-plus", reasoning: false, @@ -297,9 +296,9 @@ const BAILIAN_MODEL_CATALOG = { }, } as const; -type BailianCatalogId = keyof typeof BAILIAN_MODEL_CATALOG; +type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; -export function buildBailianModelDefinition(params: { +export function buildModelStudioModelDefinition(params: { id: string; name?: string; reasoning?: boolean; @@ -308,7 +307,7 @@ export function buildBailianModelDefinition(params: { contextWindow?: number; maxTokens?: number; }): ModelDefinitionConfig { - const catalog = BAILIAN_MODEL_CATALOG[params.id as BailianCatalogId]; + const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; return { id: params.id, name: params.name ?? catalog?.name ?? params.id, @@ -316,14 +315,14 @@ export function buildBailianModelDefinition(params: { input: (params.input as ("text" | "image")[]) ?? ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), - cost: params.cost ?? BAILIAN_DEFAULT_COST, + cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, }; } -export function buildBailianDefaultModelDefinition(): ModelDefinitionConfig { - return buildBailianModelDefinition({ - id: BAILIAN_DEFAULT_MODEL_ID, +export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { + return buildModelStudioModelDefinition({ + id: MODELSTUDIO_DEFAULT_MODEL_ID, }); } diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 8c81a0fa962..22946567fae 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -39,10 +39,10 @@ export { applyXiaomiProviderConfig, applyZaiConfig, applyZaiProviderConfig, - applyBailianConfig, - applyBailianConfigCn, - applyBailianProviderConfig, - applyBailianProviderConfigCn, + applyModelStudioConfig, + applyModelStudioConfigCn, + applyModelStudioProviderConfig, + applyModelStudioProviderConfigCn, KILOCODE_BASE_URL, } from "./onboard-auth.config-core.js"; export { @@ -88,7 +88,7 @@ export { setVolcengineApiKey, setZaiApiKey, setXaiApiKey, - setBailianApiKey, + setModelStudioApiKey, writeOAuthCredentials, HUGGINGFACE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, @@ -97,7 +97,7 @@ export { TOGETHER_DEFAULT_MODEL_REF, MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF, - BAILIAN_DEFAULT_MODEL_REF, + MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; export { buildKilocodeModelDefinition, diff --git a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts index 29aeaa1d4e9..a49be3ad2c8 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice-inference.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice-inference.ts @@ -30,8 +30,8 @@ type AuthChoiceFlagOptions = Pick< | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" - | "bailianApiKeyCn" - | "bailianApiKey" + | "modelstudioApiKeyCn" + | "modelstudioApiKey" | "volcengineApiKey" | "byteplusApiKey" | "customBaseUrl" diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 30ecc9c20ac..9739f57ce2e 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -15,8 +15,8 @@ import { applyCloudflareAiGatewayConfig, applyKilocodeConfig, applyQianfanConfig, - applyBailianConfig, - applyBailianConfigCn, + applyModelStudioConfig, + applyModelStudioConfigCn, applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxApiConfigCn, @@ -39,7 +39,7 @@ import { setCloudflareAiGatewayConfig, setByteplusApiKey, setQianfanApiKey, - setBailianApiKey, + setModelStudioApiKey, setGeminiApiKey, setKilocodeApiKey, setKimiCodingApiKey, @@ -501,13 +501,13 @@ export async function applyNonInteractiveAuthChoice(params: { return applyQianfanConfig(nextConfig); } - if (authChoice === "bailian-api-key-cn") { + if (authChoice === "modelstudio-api-key-cn") { const resolved = await resolveApiKey({ - provider: "bailian", + provider: "modelstudio", cfg: baseConfig, - flagValue: opts.bailianApiKeyCn, - flagName: "--bailian-api-key-cn", - envVar: "BAILIAN_API_KEY", + flagValue: opts.modelstudioApiKeyCn, + flagName: "--modelstudio-api-key-cn", + envVar: "MODELSTUDIO_API_KEY", runtime, }); if (!resolved) { @@ -515,26 +515,26 @@ export async function applyNonInteractiveAuthChoice(params: { } if ( !(await maybeSetResolvedApiKey(resolved, (value) => - setBailianApiKey(value, undefined, apiKeyStorageOptions), + setModelStudioApiKey(value, undefined, apiKeyStorageOptions), )) ) { return null; } nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "bailian:default", - provider: "bailian", + profileId: "modelstudio:default", + provider: "modelstudio", mode: "api_key", }); - return applyBailianConfigCn(nextConfig); + return applyModelStudioConfigCn(nextConfig); } - if (authChoice === "bailian-api-key") { + if (authChoice === "modelstudio-api-key") { const resolved = await resolveApiKey({ - provider: "bailian", + provider: "modelstudio", cfg: baseConfig, - flagValue: opts.bailianApiKey, - flagName: "--bailian-api-key", - envVar: "BAILIAN_API_KEY", + flagValue: opts.modelstudioApiKey, + flagName: "--modelstudio-api-key", + envVar: "MODELSTUDIO_API_KEY", runtime, }); if (!resolved) { @@ -542,17 +542,17 @@ export async function applyNonInteractiveAuthChoice(params: { } if ( !(await maybeSetResolvedApiKey(resolved, (value) => - setBailianApiKey(value, undefined, apiKeyStorageOptions), + setModelStudioApiKey(value, undefined, apiKeyStorageOptions), )) ) { return null; } nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "bailian:default", - provider: "bailian", + profileId: "modelstudio:default", + provider: "modelstudio", mode: "api_key", }); - return applyBailianConfig(nextConfig); + return applyModelStudioConfig(nextConfig); } if (authChoice === "openai-api-key") { diff --git a/src/commands/onboard-provider-auth-flags.ts b/src/commands/onboard-provider-auth-flags.ts index 6555caf1f91..43c552f99fb 100644 --- a/src/commands/onboard-provider-auth-flags.ts +++ b/src/commands/onboard-provider-auth-flags.ts @@ -23,8 +23,8 @@ type OnboardProviderAuthOptionKey = keyof Pick< | "xaiApiKey" | "litellmApiKey" | "qianfanApiKey" - | "bailianApiKeyCn" - | "bailianApiKey" + | "modelstudioApiKeyCn" + | "modelstudioApiKey" | "volcengineApiKey" | "byteplusApiKey" >; @@ -187,18 +187,18 @@ export const ONBOARD_PROVIDER_AUTH_FLAGS: ReadonlyArray description: "QIANFAN API key", }, { - optionKey: "bailianApiKeyCn", - authChoice: "bailian-api-key-cn", - cliFlag: "--bailian-api-key-cn", - cliOption: "--bailian-api-key-cn ", - description: "Alibaba Bailian Coding Plan API key (China)", + optionKey: "modelstudioApiKeyCn", + authChoice: "modelstudio-api-key-cn", + cliFlag: "--modelstudio-api-key-cn", + cliOption: "--modelstudio-api-key-cn ", + description: "Alibaba Cloud Model Studio Coding Plan API key (China)", }, { - optionKey: "bailianApiKey", - authChoice: "bailian-api-key", - cliFlag: "--bailian-api-key", - cliOption: "--bailian-api-key ", - description: "Alibaba Bailian Coding Plan API key (Global/Intl)", + optionKey: "modelstudioApiKey", + authChoice: "modelstudio-api-key", + cliFlag: "--modelstudio-api-key", + cliOption: "--modelstudio-api-key ", + description: "Alibaba Cloud Model Studio Coding Plan API key (Global/Intl)", }, { optionKey: "volcengineApiKey", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index e6d1bbcf806..44f4660321e 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -49,8 +49,8 @@ export type AuthChoice = | "volcengine-api-key" | "byteplus-api-key" | "qianfan-api-key" - | "bailian-api-key-cn" - | "bailian-api-key" + | "modelstudio-api-key-cn" + | "modelstudio-api-key" | "custom-api-key" | "skip"; export type AuthChoiceGroupId = @@ -77,7 +77,7 @@ export type AuthChoiceGroupId = | "together" | "huggingface" | "qianfan" - | "bailian" + | "modelstudio" | "xai" | "volcengine" | "byteplus" @@ -138,8 +138,8 @@ export type OnboardOptions = { volcengineApiKey?: string; byteplusApiKey?: string; qianfanApiKey?: string; - bailianApiKeyCn?: string; - bailianApiKey?: string; + modelstudioApiKeyCn?: string; + modelstudioApiKey?: string; customBaseUrl?: string; customApiKey?: string; customModelId?: string; diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 9d2100d1852..94fe7ae5e22 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -21,6 +21,7 @@ export const PROVIDER_ENV_VARS: Record = { xai: ["XAI_API_KEY"], mistral: ["MISTRAL_API_KEY"], kilocode: ["KILOCODE_API_KEY"], + modelstudio: ["MODELSTUDIO_API_KEY"], volcengine: ["VOLCANO_ENGINE_API_KEY"], byteplus: ["BYTEPLUS_API_KEY"], }; From 6d4241cbd940e2a7173f09632a03c6fec1b1a4d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 19:58:11 +0000 Subject: [PATCH 21/31] fix: wire modelstudio env discovery (#40634) (thanks @pomelo-nwu) --- CHANGELOG.md | 1 + src/agents/model-auth-env-vars.ts | 1 + src/agents/model-auth.profiles.test.ts | 15 +++ src/agents/models-config.e2e-harness.ts | 1 + ...odels-config.providers.modelstudio.test.ts | 32 +++++++ src/agents/models-config.providers.static.ts | 92 +++++++++++++++++++ src/agents/models-config.providers.ts | 5 + ...oard-non-interactive.provider-auth.test.ts | 20 ++++ src/config/io.ts | 1 + 9 files changed, 168 insertions(+) create mode 100644 src/agents/models-config.providers.modelstudio.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac60f101edb..6f6f3c5cd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant. - Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. - Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. +- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu. ## 2026.3.8 diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts index c366138207c..0f387bf3ce3 100644 --- a/src/agents/model-auth-env-vars.ts +++ b/src/agents/model-auth-env-vars.ts @@ -32,6 +32,7 @@ export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { mistral: ["MISTRAL_API_KEY"], together: ["TOGETHER_API_KEY"], qianfan: ["QIANFAN_API_KEY"], + modelstudio: ["MODELSTUDIO_API_KEY"], ollama: ["OLLAMA_API_KEY"], vllm: ["VLLM_API_KEY"], kilocode: ["KILOCODE_API_KEY"], diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index 5fabcf2dcc6..24a881a63cd 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -230,6 +230,21 @@ describe("getApiKeyForModel", () => { }); }); + it("resolves Model Studio API key from env", async () => { + await withEnvAsync( + { [envVar("MODELSTUDIO", "API", "KEY")]: "modelstudio-test-key" }, + async () => { + // pragma: allowlist secret + const resolved = await resolveApiKeyForProvider({ + provider: "modelstudio", + store: { version: 1, profiles: {} }, + }); + expect(resolved.apiKey).toBe("modelstudio-test-key"); + expect(resolved.source).toContain("MODELSTUDIO_API_KEY"); + }, + ); + }); + it("resolves synthetic local auth key for configured ollama provider without apiKey", async () => { await withEnvAsync({ OLLAMA_API_KEY: undefined }, async () => { const resolved = await resolveApiKeyForProvider({ diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 71577b27e69..81518ec9aee 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -101,6 +101,7 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ "OPENROUTER_API_KEY", "PI_CODING_AGENT_DIR", "QIANFAN_API_KEY", + "MODELSTUDIO_API_KEY", "QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY", "SYNTHETIC_API_KEY", diff --git a/src/agents/models-config.providers.modelstudio.test.ts b/src/agents/models-config.providers.modelstudio.test.ts new file mode 100644 index 00000000000..df4000cc27d --- /dev/null +++ b/src/agents/models-config.providers.modelstudio.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; +import { buildModelStudioProvider } from "./models-config.providers.js"; + +const modelStudioApiKeyEnv = ["MODELSTUDIO_API", "KEY"].join("_"); + +describe("Model Studio implicit provider", () => { + it("should include modelstudio when MODELSTUDIO_API_KEY is configured", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const modelStudioApiKey = "test-key"; // pragma: allowlist secret + await withEnvAsync({ [modelStudioApiKeyEnv]: modelStudioApiKey }, async () => { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.modelstudio).toBeDefined(); + expect(providers?.modelstudio?.apiKey).toBe("MODELSTUDIO_API_KEY"); + expect(providers?.modelstudio?.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + }); + }); + + it("should build the static Model Studio provider catalog", () => { + const provider = buildModelStudioProvider(); + const modelIds = provider.models.map((model) => model.id); + expect(provider.api).toBe("openai-completions"); + expect(provider.baseUrl).toBe("https://coding-intl.dashscope.aliyuncs.com/v1"); + expect(modelIds).toContain("qwen3.5-plus"); + expect(modelIds).toContain("qwen3-coder-plus"); + expect(modelIds).toContain("kimi-k2.5"); + }); +}); diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts index 0a766fe983e..08b3d1c2a66 100644 --- a/src/agents/models-config.providers.static.ts +++ b/src/agents/models-config.providers.static.ts @@ -137,6 +137,90 @@ const QIANFAN_DEFAULT_COST = { cacheWrite: 0, }; +export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +const MODELSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [ + { + id: "qwen3.5-plus", + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "qwen3-max-2026-01-23", + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3-coder-next", + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 65_536, + }, + { + id: "qwen3-coder-plus", + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "MiniMax-M2.5", + name: "MiniMax-M2.5", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, + { + id: "glm-5", + name: "glm-5", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 202_752, + maxTokens: 16_384, + }, + { + id: "glm-4.7", + name: "glm-4.7", + reasoning: false, + input: ["text"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 202_752, + maxTokens: 16_384, + }, + { + id: "kimi-k2.5", + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + cost: MODELSTUDIO_DEFAULT_COST, + contextWindow: 262_144, + maxTokens: 32_768, + }, +]; + const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"; const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct"; const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072; @@ -384,6 +468,14 @@ export function buildQianfanProvider(): ProviderConfig { }; } +export function buildModelStudioProvider(): ProviderConfig { + return { + baseUrl: MODELSTUDIO_BASE_URL, + api: "openai-completions", + models: MODELSTUDIO_MODEL_CATALOG.map((model) => ({ ...model })), + }; +} + export function buildNvidiaProvider(): ProviderConfig { return { baseUrl: NVIDIA_BASE_URL, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 8f8ffb9201c..54cbf69b182 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -29,6 +29,7 @@ import { buildKilocodeProvider, buildMinimaxPortalProvider, buildMinimaxProvider, + buildModelStudioProvider, buildMoonshotProvider, buildNvidiaProvider, buildOpenAICodexProvider, @@ -46,8 +47,11 @@ export { buildKimiCodingProvider, buildKilocodeProvider, buildNvidiaProvider, + buildModelStudioProvider, buildQianfanProvider, buildXiaomiProvider, + MODELSTUDIO_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_ID, QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, XIAOMI_DEFAULT_MODEL_ID, @@ -512,6 +516,7 @@ const SIMPLE_IMPLICIT_PROVIDER_LOADERS: ImplicitProviderLoader[] = [ apiKey, })), withApiKey("qianfan", async ({ apiKey }) => ({ ...buildQianfanProvider(), apiKey })), + withApiKey("modelstudio", async ({ apiKey }) => ({ ...buildModelStudioProvider(), apiKey })), withApiKey("openrouter", async ({ apiKey }) => ({ ...buildOpenrouterProvider(), apiKey })), withApiKey("nvidia", async ({ apiKey }) => ({ ...buildNvidiaProvider(), apiKey })), withApiKey("kilocode", async ({ apiKey }) => ({ diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index d72de28a61d..3f5ccee1755 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -611,6 +611,26 @@ describe("onboard (non-interactive): provider auth", () => { }); }); + it("infers Model Studio auth choice from --modelstudio-api-key and sets default model", async () => { + await withOnboardEnv("openclaw-onboard-modelstudio-infer-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + modelstudioApiKey: "modelstudio-test-key", // pragma: allowlist secret + }); + + expect(cfg.auth?.profiles?.["modelstudio:default"]?.provider).toBe("modelstudio"); + expect(cfg.auth?.profiles?.["modelstudio:default"]?.mode).toBe("api_key"); + expect(cfg.models?.providers?.modelstudio?.baseUrl).toBe( + "https://coding-intl.dashscope.aliyuncs.com/v1", + ); + expect(cfg.agents?.defaults?.model?.primary).toBe("modelstudio/qwen3.5-plus"); + await expectApiKeyProfile({ + profileId: "modelstudio:default", + provider: "modelstudio", + key: "modelstudio-test-key", + }); + }); + }); + it("configures a custom provider from non-interactive flags", async () => { await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ configPath, runtime }) => { await runNonInteractiveOnboardingWithDefaults(runtime, { diff --git a/src/config/io.ts b/src/config/io.ts index a4ec4cd430c..5a9026310eb 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -68,6 +68,7 @@ const SHELL_ENV_EXPECTED_KEYS = [ "OPENROUTER_API_KEY", "AI_GATEWAY_API_KEY", "MINIMAX_API_KEY", + "MODELSTUDIO_API_KEY", "SYNTHETIC_API_KEY", "KILOCODE_API_KEY", "ELEVENLABS_API_KEY", From 23cd997526098d95dc62171f271b6cf1f64eeee1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 20:02:26 +0000 Subject: [PATCH 22/31] fix: make install smoke docker-driver safe --- .github/workflows/install-smoke.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 36f64d2d6ad..f18ba38a091 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -43,6 +43,8 @@ jobs: - name: Set up Docker Builder uses: useblacksmith/setup-docker-builder@v1 + # Blacksmith can fall back to the local docker driver, which rejects gha + # cache export/import. Keep smoke builds driver-agnostic. - name: Build root Dockerfile smoke image uses: useblacksmith/build-push-action@v2 with: @@ -52,8 +54,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-root-dockerfile - cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile - name: Run root Dockerfile CLI smoke run: | @@ -73,8 +73,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-root-dockerfile-ext - cache-to: type=gha,mode=max,scope=install-smoke-root-dockerfile-ext - name: Smoke test Dockerfile with extension build arg run: | @@ -89,8 +87,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-installer-root - cache-to: type=gha,mode=max,scope=install-smoke-installer-root - name: Build installer non-root image if: github.event_name != 'pull_request' @@ -102,8 +98,6 @@ jobs: load: true push: false provenance: false - cache-from: type=gha,scope=install-smoke-installer-nonroot - cache-to: type=gha,mode=max,scope=install-smoke-installer-nonroot - name: Run installer docker tests env: From 0976317f960f8627f53a073235f6481781d7a7c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 20:22:56 +0000 Subject: [PATCH 23/31] test: deduplicate diffs extension fixtures --- extensions/diffs/src/browser.test.ts | 7 +- extensions/diffs/src/http.test.ts | 150 +++++++++------------------ extensions/diffs/src/store.test.ts | 12 ++- extensions/diffs/src/test-helpers.ts | 30 ++++++ extensions/diffs/src/tool.test.ts | 9 +- 5 files changed, 97 insertions(+), 111 deletions(-) create mode 100644 extensions/diffs/src/test-helpers.ts diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index 9c3cf1365ea..c0b03d62cc0 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTempDiffRoot } from "./test-helpers.js"; const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn(), @@ -17,10 +17,11 @@ vi.mock("playwright-core", () => ({ describe("PlaywrightDiffScreenshotter", () => { let rootDir: string; let outputPath: string; + let cleanupRootDir: () => Promise; beforeEach(async () => { vi.useFakeTimers(); - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-browser-")); + ({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-")); outputPath = path.join(rootDir, "preview.png"); launchMock.mockReset(); const browserModule = await import("./browser.js"); @@ -31,7 +32,7 @@ describe("PlaywrightDiffScreenshotter", () => { const browserModule = await import("./browser.js"); await browserModule.resetSharedBrowserStateForTests(); vi.useRealTimers(); - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("reuses the same browser across renders and closes it after the idle window", async () => { diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 5e8c2927691..43216580379 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -1,32 +1,24 @@ -import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js"; import { createDiffsHttpHandler } from "./http.js"; import { DiffArtifactStore } from "./store.js"; +import { createDiffStoreHarness } from "./test-helpers.js"; describe("createDiffsHttpHandler", () => { - let rootDir: string; let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-")); - store = new DiffArtifactStore({ rootDir }); + ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); }); afterEach(async () => { - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("serves a stored diff document", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); + const artifact = await createViewerArtifact(store); const handler = createDiffsHttpHandler({ store }); const res = createMockServerResponse(); @@ -45,12 +37,7 @@ describe("createDiffsHttpHandler", () => { }); it("rejects invalid tokens", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); + const artifact = await createViewerArtifact(store); const handler = createDiffsHttpHandler({ store }); const res = createMockServerResponse(); @@ -113,96 +100,52 @@ describe("createDiffsHttpHandler", () => { expect(String(res.body)).toContain("openclawDiffsReady"); }); - it("blocks non-loopback viewer access by default", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); + it.each([ + { + name: "blocks non-loopback viewer access by default", + request: remoteReq, + allowRemoteViewer: false, + expectedStatusCode: 404, + }, + { + name: "blocks loopback requests that carry proxy forwarding headers by default", + request: localReq, + headers: { "x-forwarded-for": "203.0.113.10" }, + allowRemoteViewer: false, + expectedStatusCode: 404, + }, + { + name: "allows remote access when allowRemoteViewer is enabled", + request: remoteReq, + allowRemoteViewer: true, + expectedStatusCode: 200, + }, + { + name: "allows proxied loopback requests when allowRemoteViewer is enabled", + request: localReq, + headers: { "x-forwarded-for": "203.0.113.10" }, + allowRemoteViewer: true, + expectedStatusCode: 200, + }, + ])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => { + const artifact = await createViewerArtifact(store); - const handler = createDiffsHttpHandler({ store }); + const handler = createDiffsHttpHandler({ store, allowRemoteViewer }); const res = createMockServerResponse(); const handled = await handler( - remoteReq({ + request({ method: "GET", url: artifact.viewerPath, + headers, }), res, ); expect(handled).toBe(true); - expect(res.statusCode).toBe(404); - }); - - it("blocks loopback requests that carry proxy forwarding headers by default", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - headers: { "x-forwarded-for": "203.0.113.10" }, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(404); - }); - - it("allows remote access when allowRemoteViewer is enabled", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); - - const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); - const res = createMockServerResponse(); - const handled = await handler( - remoteReq({ - method: "GET", - url: artifact.viewerPath, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("viewer"); - }); - - it("allows proxied loopback requests when allowRemoteViewer is enabled", async () => { - const artifact = await store.createArtifact({ - html: "viewer", - title: "Demo", - inputKind: "before_after", - fileCount: 1, - }); - - const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - headers: { "x-forwarded-for": "203.0.113.10" }, - }), - res, - ); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("viewer"); + expect(res.statusCode).toBe(expectedStatusCode); + if (expectedStatusCode === 200) { + expect(res.body).toBe("viewer"); + } }); it("rate-limits repeated remote misses", async () => { @@ -232,6 +175,15 @@ describe("createDiffsHttpHandler", () => { }); }); +async function createViewerArtifact(store: DiffArtifactStore) { + return await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); +} + function localReq(input: { method: string; url: string; diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index d4e6aacd409..8039865b71b 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -1,21 +1,25 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DiffArtifactStore } from "./store.js"; +import { createDiffStoreHarness } from "./test-helpers.js"; describe("DiffArtifactStore", () => { let rootDir: string; let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-store-")); - store = new DiffArtifactStore({ rootDir }); + ({ + rootDir, + store, + cleanup: cleanupRootDir, + } = await createDiffStoreHarness("openclaw-diffs-store-")); }); afterEach(async () => { vi.useRealTimers(); - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("creates and retrieves an artifact", async () => { diff --git a/extensions/diffs/src/test-helpers.ts b/extensions/diffs/src/test-helpers.ts new file mode 100644 index 00000000000..f97ed9573e1 --- /dev/null +++ b/extensions/diffs/src/test-helpers.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DiffArtifactStore } from "./store.js"; + +export async function createTempDiffRoot(prefix: string): Promise<{ + rootDir: string; + cleanup: () => Promise; +}> { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + return { + rootDir, + cleanup: async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +export async function createDiffStoreHarness(prefix: string): Promise<{ + rootDir: string; + store: DiffArtifactStore; + cleanup: () => Promise; +}> { + const { rootDir, cleanup } = await createTempDiffRoot(prefix); + return { + rootDir, + store: new DiffArtifactStore({ rootDir }), + cleanup, + }; +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 97ee6234148..416bdf8dc14 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -1,25 +1,24 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; +import { createDiffStoreHarness } from "./test-helpers.js"; import { createDiffsTool } from "./tool.js"; import type { DiffRenderOptions } from "./types.js"; describe("diffs tool", () => { - let rootDir: string; let store: DiffArtifactStore; + let cleanupRootDir: () => Promise; beforeEach(async () => { - rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-tool-")); - store = new DiffArtifactStore({ rootDir }); + ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-tool-")); }); afterEach(async () => { - await fs.rm(rootDir, { recursive: true, force: true }); + await cleanupRootDir(); }); it("returns a viewer URL in view mode", async () => { From 283570de4da2c4d12f02c069fe1417579f0841b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 20:23:03 +0000 Subject: [PATCH 24/31] fix: normalize stale openai completions transport --- .../model.provider-normalization.ts | 26 +++++++++- src/agents/pi-embedded-runner/model.test.ts | 48 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner/model.provider-normalization.ts b/src/agents/pi-embedded-runner/model.provider-normalization.ts index ecf1a25e7d3..82dabff7c1b 100644 --- a/src/agents/pi-embedded-runner/model.provider-normalization.ts +++ b/src/agents/pi-embedded-runner/model.provider-normalization.ts @@ -54,9 +54,33 @@ function normalizeOpenAICodexTransport(params: { } as Model; } +function normalizeOpenAITransport(params: { provider: string; model: Model }): Model { + if (normalizeProviderId(params.provider) !== "openai") { + return params.model; + } + + const useResponsesTransport = + params.model.api === "openai-completions" && + (!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl)); + + if (!useResponsesTransport) { + return params.model; + } + + return { + ...params.model, + api: "openai-responses", + } as Model; +} + export function normalizeResolvedProviderModel(params: { provider: string; model: Model; }): Model { - return normalizeModelCompat(normalizeOpenAICodexTransport(params)); + const normalizedOpenAI = normalizeOpenAITransport(params); + const normalizedCodex = normalizeOpenAICodexTransport({ + provider: params.provider, + model: normalizedOpenAI, + }); + return normalizeModelCompat(normalizedCodex); } diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index e67fb2c2898..60b34684866 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -518,6 +518,54 @@ describe("resolveModel", () => { }); }); + it("normalizes stale native openai gpt-5.4 completions transport to responses", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5.4", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + api: "openai-completions", + baseUrl: "https://api.openai.com/v1", + }), + }); + + const result = resolveModel("openai", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("keeps proxied openai completions transport untouched", () => { + mockDiscoveredModel({ + provider: "openai", + modelId: "gpt-5.4", + templateModel: buildForwardCompatTemplate({ + id: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + api: "openai-completions", + baseUrl: "https://proxy.example.com/v1", + }), + }); + + const result = resolveModel("openai", "gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openai", + id: "gpt-5.4", + api: "openai-completions", + baseUrl: "https://proxy.example.com/v1", + }); + }); + it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => { mockDiscoveredModel({ provider: "anthropic", From 158a3b49a7a7d814a3d2a76601af59bf167b40ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 20:34:54 +0000 Subject: [PATCH 25/31] test: deduplicate cli option collision fixtures --- src/cli/acp-cli.option-collisions.test.ts | 84 ++++++-------- .../register-service-commands.test.ts | 59 +++++----- .../register.option-collisions.test.ts | 54 +++++---- .../gateway-cli/run.option-collisions.test.ts | 108 ++++++++---------- src/cli/update-cli.option-collisions.test.ts | 46 ++++---- src/test-utils/secret-file-fixture.ts | 30 +++++ 6 files changed, 201 insertions(+), 180 deletions(-) create mode 100644 src/test-utils/secret-file-fixture.ts diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index 131db6a67cb..068f415de79 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -1,9 +1,7 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runRegisteredCli } from "../test-utils/command-runner.js"; +import { withTempSecretFiles } from "../test-utils/secret-file-fixture.js"; const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {}); const serveAcpGateway = vi.fn(async (_opts: unknown) => {}); @@ -30,27 +28,6 @@ vi.mock("../runtime.js", () => ({ describe("acp cli option collisions", () => { let registerAcpCli: typeof import("./acp-cli.js").registerAcpCli; - async function withSecretFiles( - secrets: { token?: string; password?: string }, - run: (files: { tokenFile?: string; passwordFile?: string }) => Promise, - ): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-cli-")); - try { - const files: { tokenFile?: string; passwordFile?: string } = {}; - if (secrets.token !== undefined) { - files.tokenFile = path.join(dir, "token.txt"); - await fs.writeFile(files.tokenFile, secrets.token, "utf8"); - } - if (secrets.password !== undefined) { - files.passwordFile = path.join(dir, "password.txt"); - await fs.writeFile(files.passwordFile, secrets.password, "utf8"); - } - return await run(files); - } finally { - await fs.rm(dir, { recursive: true, force: true }); - } - } - function createAcpProgram() { const program = new Command(); registerAcpCli(program); @@ -93,15 +70,19 @@ describe("acp cli option collisions", () => { }); it("loads gateway token/password from files", async () => { - await withSecretFiles({ token: "tok_file\n", [passwordKey()]: "pw_file\n" }, async (files) => { - // pragma: allowlist secret - await parseAcp([ - "--token-file", - files.tokenFile ?? "", - "--password-file", - files.passwordFile ?? "", - ]); - }); + await withTempSecretFiles( + "openclaw-acp-cli-", + { token: "tok_file\n", [passwordKey()]: "pw_file\n" }, + async (files) => { + // pragma: allowlist secret + await parseAcp([ + "--token-file", + files.tokenFile ?? "", + "--password-file", + files.passwordFile ?? "", + ]); + }, + ); expect(serveAcpGateway).toHaveBeenCalledWith( expect.objectContaining({ @@ -111,21 +92,30 @@ describe("acp cli option collisions", () => { ); }); - it("rejects mixed secret flags and file flags", async () => { - await withSecretFiles({ token: "tok_file\n" }, async (files) => { - await parseAcp(["--token", "tok_inline", "--token-file", files.tokenFile ?? ""]); + it.each([ + { + name: "rejects mixed secret flags and file flags", + files: { token: "tok_file\n" }, + args: (tokenFile: string) => ["--token", "tok_inline", "--token-file", tokenFile], + expected: /Use either --token or --token-file/, + }, + { + name: "rejects mixed password flags and file flags", + files: { password: "pw_file\n" }, // pragma: allowlist secret + args: (_tokenFile: string, passwordFile: string) => [ + "--password", + "pw_inline", + "--password-file", + passwordFile, + ], + expected: /Use either --password or --password-file/, + }, + ])("$name", async ({ files, args, expected }) => { + await withTempSecretFiles("openclaw-acp-cli-", files, async ({ tokenFile, passwordFile }) => { + await parseAcp(args(tokenFile ?? "", passwordFile ?? "")); }); - expectCliError(/Use either --token or --token-file/); - }); - - it("rejects mixed password flags and file flags", async () => { - const passwordFileValue = "pw_file\n"; // pragma: allowlist secret - await withSecretFiles({ password: passwordFileValue }, async (files) => { - await parseAcp(["--password", "pw_inline", "--password-file", files.passwordFile ?? ""]); - }); - - expectCliError(/Use either --password or --password-file/); + expectCliError(expected); }); it("warns when inline secret flags are used", async () => { @@ -140,7 +130,7 @@ describe("acp cli option collisions", () => { }); it("trims token file path before reading", async () => { - await withSecretFiles({ token: "tok_file\n" }, async (files) => { + await withTempSecretFiles("openclaw-acp-cli-", { token: "tok_file\n" }, async (files) => { await parseAcp(["--token-file", ` ${files.tokenFile ?? ""} `]); }); diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index cec45d62769..e249b00c835 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -39,34 +39,37 @@ describe("addGatewayServiceCommands", () => { runDaemonUninstall.mockClear(); }); - it("forwards install option collisions from parent gateway command", async () => { + it.each([ + { + name: "forwards install option collisions from parent gateway command", + argv: ["install", "--force", "--port", "19000", "--token", "tok_test"], + assert: () => { + expect(runDaemonInstall).toHaveBeenCalledWith( + expect.objectContaining({ + force: true, + port: "19000", + token: "tok_test", + }), + ); + }, + }, + { + name: "forwards status auth collisions from parent gateway command", + argv: ["status", "--token", "tok_status", "--password", "pw_status"], + assert: () => { + expect(runDaemonStatus).toHaveBeenCalledWith( + expect.objectContaining({ + rpc: expect.objectContaining({ + token: "tok_status", + password: "pw_status", // pragma: allowlist secret + }), + }), + ); + }, + }, + ])("$name", async ({ argv, assert }) => { const gateway = createGatewayParentLikeCommand(); - await gateway.parseAsync(["install", "--force", "--port", "19000", "--token", "tok_test"], { - from: "user", - }); - - expect(runDaemonInstall).toHaveBeenCalledWith( - expect.objectContaining({ - force: true, - port: "19000", - token: "tok_test", - }), - ); - }); - - it("forwards status auth collisions from parent gateway command", async () => { - const gateway = createGatewayParentLikeCommand(); - await gateway.parseAsync(["status", "--token", "tok_status", "--password", "pw_status"], { - from: "user", - }); - - expect(runDaemonStatus).toHaveBeenCalledWith( - expect.objectContaining({ - rpc: expect.objectContaining({ - token: "tok_status", - password: "pw_status", // pragma: allowlist secret - }), - }), - ); + await gateway.parseAsync(argv, { from: "user" }); + assert(); }); }); diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index 1ef5ba2c238..665886c76eb 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -128,30 +128,34 @@ describe("gateway register option collisions", () => { gatewayStatusCommand.mockClear(); }); - it("forwards --token to gateway call when parent and child option names collide", async () => { - await sharedProgram.parseAsync(["gateway", "call", "health", "--token", "tok_call", "--json"], { - from: "user", - }); - - expect(callGatewayCli).toHaveBeenCalledWith( - "health", - expect.objectContaining({ - token: "tok_call", - }), - {}, - ); - }); - - it("forwards --token to gateway probe when parent and child option names collide", async () => { - await sharedProgram.parseAsync(["gateway", "probe", "--token", "tok_probe", "--json"], { - from: "user", - }); - - expect(gatewayStatusCommand).toHaveBeenCalledWith( - expect.objectContaining({ - token: "tok_probe", - }), - defaultRuntime, - ); + it.each([ + { + name: "forwards --token to gateway call when parent and child option names collide", + argv: ["gateway", "call", "health", "--token", "tok_call", "--json"], + assert: () => { + expect(callGatewayCli).toHaveBeenCalledWith( + "health", + expect.objectContaining({ + token: "tok_call", + }), + {}, + ); + }, + }, + { + name: "forwards --token to gateway probe when parent and child option names collide", + argv: ["gateway", "probe", "--token", "tok_probe", "--json"], + assert: () => { + expect(gatewayStatusCommand).toHaveBeenCalledWith( + expect.objectContaining({ + token: "tok_probe", + }), + defaultRuntime, + ); + }, + }, + ])("$name", async ({ argv, assert }) => { + await sharedProgram.parseAsync(argv, { from: "user" }); + assert(); }); }); diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 3a1f8bf57c7..a896a7a3f76 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,8 +1,6 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js"; import { createCliRuntimeCapture } from "../test-runtime-capture.js"; const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({ @@ -195,16 +193,10 @@ describe("gateway run option collisions", () => { ); }); - it("accepts --auth none override", async () => { - await runGatewayCli(["gateway", "run", "--auth", "none", "--allow-unconfigured"]); + it.each(["none", "trusted-proxy"] as const)("accepts --auth %s override", async (mode) => { + await runGatewayCli(["gateway", "run", "--auth", mode, "--allow-unconfigured"]); - expectAuthOverrideMode("none"); - }); - - it("accepts --auth trusted-proxy override", async () => { - await runGatewayCli(["gateway", "run", "--auth", "trusted-proxy", "--allow-unconfigured"]); - - expectAuthOverrideMode("trusted-proxy"); + expectAuthOverrideMode(mode); }); it("prints all supported modes on invalid --auth value", async () => { @@ -244,36 +236,34 @@ describe("gateway run option collisions", () => { }); it("reads gateway password from --password-file", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-")); - try { - const passwordFile = path.join(tempDir, "gateway-password.txt"); - await fs.writeFile(passwordFile, "pw_from_file\n", "utf8"); + await withTempSecretFiles( + "openclaw-gateway-run-", + { password: "pw_from_file\n" }, + async ({ passwordFile }) => { + await runGatewayCli([ + "gateway", + "run", + "--auth", + "password", + "--password-file", + passwordFile ?? "", + "--allow-unconfigured", + ]); + }, + ); - await runGatewayCli([ - "gateway", - "run", - "--auth", - "password", - "--password-file", - passwordFile, - "--allow-unconfigured", - ]); - - expect(startGatewayServer).toHaveBeenCalledWith( - 18789, - expect.objectContaining({ - auth: expect.objectContaining({ - mode: "password", - password: "pw_from_file", // pragma: allowlist secret - }), + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + auth: expect.objectContaining({ + mode: "password", + password: "pw_from_file", // pragma: allowlist secret }), - ); - expect(runtimeErrors).not.toContain( - "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", - ); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + }), + ); + expect(runtimeErrors).not.toContain( + "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", + ); }); it("warns when gateway password is passed inline", async () => { @@ -293,26 +283,24 @@ describe("gateway run option collisions", () => { }); it("rejects using both --password and --password-file", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-")); - try { - const passwordFile = path.join(tempDir, "gateway-password.txt"); - await fs.writeFile(passwordFile, "pw_from_file\n", "utf8"); + await withTempSecretFiles( + "openclaw-gateway-run-", + { password: "pw_from_file\n" }, + async ({ passwordFile }) => { + await expect( + runGatewayCli([ + "gateway", + "run", + "--password", + "pw_inline", + "--password-file", + passwordFile ?? "", + "--allow-unconfigured", + ]), + ).rejects.toThrow("__exit__:1"); + }, + ); - await expect( - runGatewayCli([ - "gateway", - "run", - "--password", - "pw_inline", - "--password-file", - passwordFile, - "--allow-unconfigured", - ]), - ).rejects.toThrow("__exit__:1"); - - expect(runtimeErrors).toContain("Use either --password or --password-file."); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + expect(runtimeErrors).toContain("Use either --password or --password-file."); }); }); diff --git a/src/cli/update-cli.option-collisions.test.ts b/src/cli/update-cli.option-collisions.test.ts index c0dd2d88404..6db4cfdd260 100644 --- a/src/cli/update-cli.option-collisions.test.ts +++ b/src/cli/update-cli.option-collisions.test.ts @@ -44,30 +44,36 @@ describe("update cli option collisions", () => { defaultRuntime.exit.mockClear(); }); - it("forwards parent-captured --json/--timeout to `update status`", async () => { - await runRegisteredCli({ - register: registerUpdateCli as (program: Command) => void, + it.each([ + { + name: "forwards parent-captured --json/--timeout to `update status`", argv: ["update", "status", "--json", "--timeout", "9"], - }); - - expect(updateStatusCommand).toHaveBeenCalledWith( - expect.objectContaining({ - json: true, - timeout: "9", - }), - ); - }); - - it("forwards parent-captured --timeout to `update wizard`", async () => { + assert: () => { + expect(updateStatusCommand).toHaveBeenCalledWith( + expect.objectContaining({ + json: true, + timeout: "9", + }), + ); + }, + }, + { + name: "forwards parent-captured --timeout to `update wizard`", + argv: ["update", "wizard", "--timeout", "13"], + assert: () => { + expect(updateWizardCommand).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: "13", + }), + ); + }, + }, + ])("$name", async ({ argv, assert }) => { await runRegisteredCli({ register: registerUpdateCli as (program: Command) => void, - argv: ["update", "wizard", "--timeout", "13"], + argv, }); - expect(updateWizardCommand).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: "13", - }), - ); + assert(); }); }); diff --git a/src/test-utils/secret-file-fixture.ts b/src/test-utils/secret-file-fixture.ts new file mode 100644 index 00000000000..8e780929f94 --- /dev/null +++ b/src/test-utils/secret-file-fixture.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export type SecretFiles = { + passwordFile?: string; + tokenFile?: string; +}; + +export async function withTempSecretFiles( + prefix: string, + secrets: { password?: string; token?: string }, + run: (files: SecretFiles) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + const files: SecretFiles = {}; + if (secrets.token !== undefined) { + files.tokenFile = path.join(dir, "token.txt"); + await fs.writeFile(files.tokenFile, secrets.token, "utf8"); + } + if (secrets.password !== undefined) { + files.passwordFile = path.join(dir, "password.txt"); + await fs.writeFile(files.passwordFile, secrets.password, "utf8"); + } + return await run(files); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} From f209a9be801e6feb308824f108f0287d9c6a1cf7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 10 Mar 2026 20:35:03 +0000 Subject: [PATCH 26/31] test: extract sendpayload outbound contract suite --- .../zalo/src/channel.sendpayload.test.ts | 88 ++--------- .../zalouser/src/channel.sendpayload.test.ts | 88 ++--------- .../direct-text-media.sendpayload.test.ts | 114 +++------------ .../outbound/discord.sendpayload.test.ts | 107 +++----------- .../outbound/slack.sendpayload.test.ts | 105 ++++--------- .../outbound/whatsapp.sendpayload.test.ts | 115 +++------------ src/test-utils/send-payload-contract.ts | 138 ++++++++++++++++++ 7 files changed, 263 insertions(+), 492 deletions(-) create mode 100644 src/test-utils/send-payload-contract.ts diff --git a/extensions/zalo/src/channel.sendpayload.test.ts b/extensions/zalo/src/channel.sendpayload.test.ts index 6cc072ac6dd..27acb737f9f 100644 --- a/extensions/zalo/src/channel.sendpayload.test.ts +++ b/extensions/zalo/src/channel.sendpayload.test.ts @@ -1,5 +1,9 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalo"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../src/test-utils/send-payload-contract.js"; import { zaloPlugin } from "./channel.js"; vi.mock("./send.js", () => ({ @@ -25,78 +29,16 @@ describe("zaloPlugin outbound sendPayload", () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" }); }); - it("text-only delegates to sendText", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" }); - - const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" })); - - expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" }); - }); - - it("single media delegates to sendMedia", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" }); - - const result = await zaloPlugin.outbound!.sendPayload!( - baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }), - ); - - expect(mockedSend).toHaveBeenCalledWith( - "123456789", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalo" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zl-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zl-2" }); - - const result = await zaloPlugin.outbound!.sendPayload!( - baseCtx({ - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }), - ); - - expect(mockedSend).toHaveBeenCalledTimes(2); - expect(mockedSend).toHaveBeenNthCalledWith( - 1, - "123456789", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(mockedSend).toHaveBeenNthCalledWith( - 2, - "123456789", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" }); - }); - - it("empty payload returns no-op", async () => { - const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({})); - - expect(mockedSend).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "zalo", messageId: "" }); - }); - - it("chunking splits long text", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zl-c1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zl-c2" }); - - const longText = "a".repeat(3000); - const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText })); - - // textChunkLimit is 2000 with chunkTextForOutbound, so it should split - expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of mockedSend.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(2000); - } - expect(result).toMatchObject({ channel: "zalo" }); + installSendPayloadContractSuite({ + channel: "zalo", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults); + return { + run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)), + sendMock: mockedSend, + to: "123456789", + }; + }, }); }); diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 534f9c39b95..0cef65f8c05 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -1,5 +1,9 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../src/test-utils/send-payload-contract.js"; import { zalouserPlugin } from "./channel.js"; vi.mock("./send.js", () => ({ @@ -40,15 +44,6 @@ describe("zalouserPlugin outbound sendPayload", () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" }); }); - it("text-only delegates to sendText", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-t1" }); - - const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" })); - - expect(mockedSend).toHaveBeenCalledWith("987654321", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-t1" }); - }); - it("group target delegates with isGroup=true and stripped threadId", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" }); @@ -65,21 +60,6 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" }); }); - it("single media delegates to sendMedia", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-m1" }); - - const result = await zalouserPlugin.outbound!.sendPayload!( - baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }), - ); - - expect(mockedSend).toHaveBeenCalledWith( - "987654321", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalouser" }); - }); - it("treats bare numeric targets as direct chats for backward compatibility", async () => { mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" }); @@ -112,55 +92,17 @@ describe("zalouserPlugin outbound sendPayload", () => { expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" }); }); - it("multi-media iterates URLs with caption on first", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zlu-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zlu-2" }); - - const result = await zalouserPlugin.outbound!.sendPayload!( - baseCtx({ - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }), - ); - - expect(mockedSend).toHaveBeenCalledTimes(2); - expect(mockedSend).toHaveBeenNthCalledWith( - 1, - "987654321", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(mockedSend).toHaveBeenNthCalledWith( - 2, - "987654321", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-2" }); - }); - - it("empty payload returns no-op", async () => { - const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({})); - - expect(mockedSend).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "zalouser", messageId: "" }); - }); - - it("chunking splits long text", async () => { - mockedSend - .mockResolvedValueOnce({ ok: true, messageId: "zlu-c1" }) - .mockResolvedValueOnce({ ok: true, messageId: "zlu-c2" }); - - const longText = "a".repeat(3000); - const result = await zalouserPlugin.outbound!.sendPayload!(baseCtx({ text: longText })); - - // textChunkLimit is 2000 with chunkTextForOutbound, so it should split - expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of mockedSend.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(2000); - } - expect(result).toMatchObject({ channel: "zalouser" }); + installSendPayloadContractSuite({ + channel: "zalouser", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults); + return { + run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)), + sendMock: mockedSend, + to: "987654321", + }; + }, }); }); diff --git a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts index 0e5c2ba01db..42971f1e89c 100644 --- a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts +++ b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts @@ -1,9 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { createDirectTextMediaOutbound } from "./direct-text-media.js"; -function makeOutbound() { - const sendFn = vi.fn().mockResolvedValue({ messageId: "m1" }); +function createDirectHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendFn = vi.fn(); + primeSendMock(sendFn, { messageId: "m1" }, params.sendResults); const outbound = createDirectTextMediaOutbound({ channel: "imessage", resolveSender: () => sendFn, @@ -24,94 +32,16 @@ function baseCtx(payload: ReplyPayload) { } describe("createDirectTextMediaOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const { outbound, sendFn } = makeOutbound(); - const result = await outbound.sendPayload!(baseCtx({ text: "hello" })); - - expect(sendFn).toHaveBeenCalledTimes(1); - expect(sendFn).toHaveBeenCalledWith("user1", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "imessage", messageId: "m1" }); - }); - - it("single media delegates to sendMedia", async () => { - const { outbound, sendFn } = makeOutbound(); - const result = await outbound.sendPayload!( - baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }), - ); - - expect(sendFn).toHaveBeenCalledTimes(1); - expect(sendFn).toHaveBeenCalledWith( - "user1", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "imessage", messageId: "m1" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendFn = vi - .fn() - .mockResolvedValueOnce({ messageId: "m1" }) - .mockResolvedValueOnce({ messageId: "m2" }); - const outbound = createDirectTextMediaOutbound({ - channel: "imessage", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - const result = await outbound.sendPayload!( - baseCtx({ - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }), - ); - - expect(sendFn).toHaveBeenCalledTimes(2); - expect(sendFn).toHaveBeenNthCalledWith( - 1, - "user1", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendFn).toHaveBeenNthCalledWith( - 2, - "user1", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "imessage", messageId: "m2" }); - }); - - it("empty payload returns no-op", async () => { - const { outbound, sendFn } = makeOutbound(); - const result = await outbound.sendPayload!(baseCtx({})); - - expect(sendFn).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "imessage", messageId: "" }); - }); - - it("chunking splits long text", async () => { - const sendFn = vi - .fn() - .mockResolvedValueOnce({ messageId: "c1" }) - .mockResolvedValueOnce({ messageId: "c2" }); - const outbound = createDirectTextMediaOutbound({ - channel: "signal", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - // textChunkLimit is 4000; generate text exceeding that - const longText = "a".repeat(5000); - const result = await outbound.sendPayload!(baseCtx({ text: longText })); - - expect(sendFn.mock.calls.length).toBeGreaterThanOrEqual(2); - // Each chunk should be within the limit - for (const call of sendFn.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(4000); - } - expect(result).toMatchObject({ channel: "signal" }); + installSendPayloadContractSuite({ + channel: "imessage", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: ({ payload, sendResults }) => { + const { outbound, sendFn } = createDirectHarness({ payload, sendResults }); + return { + run: async () => await outbound.sendPayload!(baseCtx(payload)), + sendMock: sendFn, + to: "user1", + }; + }, }); }); diff --git a/src/channels/plugins/outbound/discord.sendpayload.test.ts b/src/channels/plugins/outbound/discord.sendpayload.test.ts index 07c821d6e79..168f8d8d927 100644 --- a/src/channels/plugins/outbound/discord.sendpayload.test.ts +++ b/src/channels/plugins/outbound/discord.sendpayload.test.ts @@ -1,98 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { discordOutbound } from "./discord.js"; -function baseCtx(payload: ReplyPayload) { - return { +function createHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendDiscord = vi.fn(); + primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults); + const ctx = { cfg: {}, to: "channel:123456", text: "", - payload, + payload: params.payload, deps: { - sendDiscord: vi.fn().mockResolvedValue({ messageId: "dc-1", channelId: "123456" }), + sendDiscord, }, }; + return { + run: async () => await discordOutbound.sendPayload!(ctx), + sendMock: sendDiscord, + to: ctx.to, + }; } describe("discordOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const ctx = baseCtx({ text: "hello" }); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendDiscord).toHaveBeenCalledWith( - "channel:123456", - "hello", - expect.any(Object), - ); - expect(result).toMatchObject({ channel: "discord" }); - }); - - it("single media delegates to sendMedia", async () => { - const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendDiscord).toHaveBeenCalledWith( - "channel:123456", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "discord" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendDiscord = vi - .fn() - .mockResolvedValueOnce({ messageId: "dc-1", channelId: "123456" }) - .mockResolvedValueOnce({ messageId: "dc-2", channelId: "123456" }); - const ctx = { - cfg: {}, - to: "channel:123456", - text: "", - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - } as ReplyPayload, - deps: { sendDiscord }, - }; - const result = await discordOutbound.sendPayload!(ctx); - - expect(sendDiscord).toHaveBeenCalledTimes(2); - expect(sendDiscord).toHaveBeenNthCalledWith( - 1, - "channel:123456", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendDiscord).toHaveBeenNthCalledWith( - 2, - "channel:123456", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "discord", messageId: "dc-2" }); - }); - - it("empty payload returns no-op", async () => { - const ctx = baseCtx({}); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "discord", messageId: "" }); - }); - - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - // Discord has chunker: null, so long text should be sent as a single message - const ctx = baseCtx({ text: "a".repeat(3000) }); - const result = await discordOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendDiscord).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendDiscord).toHaveBeenCalledWith( - "channel:123456", - "a".repeat(3000), - expect.any(Object), - ); - expect(result).toMatchObject({ channel: "discord" }); + installSendPayloadContractSuite({ + channel: "discord", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness, }); }); diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index c6df264df12..374c9881a73 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,92 +1,41 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { slackOutbound } from "./slack.js"; -function baseCtx(payload: ReplyPayload) { - return { +function createHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendSlack = vi.fn(); + primeSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { cfg: {}, to: "C12345", text: "", - payload, + payload: params.payload, deps: { - sendSlack: vi - .fn() - .mockResolvedValue({ messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }), + sendSlack, }, }; + return { + run: async () => await slackOutbound.sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; } describe("slackOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const ctx = baseCtx({ text: "hello" }); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendSlack).toHaveBeenCalledWith("C12345", "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: "slack" }); - }); - - it("single media delegates to sendMedia", async () => { - const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendSlack).toHaveBeenCalledWith( - "C12345", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "slack" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendSlack = vi - .fn() - .mockResolvedValueOnce({ messageId: "sl-1", channelId: "C12345" }) - .mockResolvedValueOnce({ messageId: "sl-2", channelId: "C12345" }); - const ctx = { - cfg: {}, - to: "C12345", - text: "", - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - } as ReplyPayload, - deps: { sendSlack }, - }; - const result = await slackOutbound.sendPayload!(ctx); - - expect(sendSlack).toHaveBeenCalledTimes(2); - expect(sendSlack).toHaveBeenNthCalledWith( - 1, - "C12345", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendSlack).toHaveBeenNthCalledWith( - 2, - "C12345", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "slack", messageId: "sl-2" }); - }); - - it("empty payload returns no-op", async () => { - const ctx = baseCtx({}); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "slack", messageId: "" }); - }); - - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - // Slack has chunker: null, so long text should be sent as a single message - const ctx = baseCtx({ text: "a".repeat(5000) }); - const result = await slackOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendSlack).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendSlack).toHaveBeenCalledWith("C12345", "a".repeat(5000), expect.any(Object)); - expect(result).toMatchObject({ channel: "slack" }); + installSendPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness, }); }); diff --git a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts index 3eb6f7467dc..e98351cfa61 100644 --- a/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts +++ b/src/channels/plugins/outbound/whatsapp.sendpayload.test.ts @@ -1,106 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { + installSendPayloadContractSuite, + primeSendMock, +} from "../../../test-utils/send-payload-contract.js"; import { whatsappOutbound } from "./whatsapp.js"; -function baseCtx(payload: ReplyPayload) { - return { +function createHarness(params: { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}) { + const sendWhatsApp = vi.fn(); + primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); + const ctx = { cfg: {}, to: "5511999999999@c.us", text: "", - payload, + payload: params.payload, deps: { - sendWhatsApp: vi.fn().mockResolvedValue({ messageId: "wa-1" }), + sendWhatsApp, }, }; + return { + run: async () => await whatsappOutbound.sendPayload!(ctx), + sendMock: sendWhatsApp, + to: ctx.to, + }; } describe("whatsappOutbound sendPayload", () => { - it("text-only delegates to sendText", async () => { - const ctx = baseCtx({ text: "hello" }); - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledWith( - "5511999999999@c.us", - "hello", - expect.any(Object), - ); - expect(result).toMatchObject({ channel: "whatsapp", messageId: "wa-1" }); - }); - - it("single media delegates to sendMedia", async () => { - const ctx = baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }); - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledTimes(1); - expect(ctx.deps.sendWhatsApp).toHaveBeenCalledWith( - "5511999999999@c.us", - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: "whatsapp" }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const sendWhatsApp = vi - .fn() - .mockResolvedValueOnce({ messageId: "wa-1" }) - .mockResolvedValueOnce({ messageId: "wa-2" }); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - } as ReplyPayload, - deps: { sendWhatsApp }, - }; - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(sendWhatsApp).toHaveBeenCalledTimes(2); - expect(sendWhatsApp).toHaveBeenNthCalledWith( - 1, - "5511999999999@c.us", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendWhatsApp).toHaveBeenNthCalledWith( - 2, - "5511999999999@c.us", - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: "whatsapp", messageId: "wa-2" }); - }); - - it("empty payload returns no-op", async () => { - const ctx = baseCtx({}); - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(ctx.deps.sendWhatsApp).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "whatsapp", messageId: "" }); - }); - - it("chunking splits long text", async () => { - const sendWhatsApp = vi - .fn() - .mockResolvedValueOnce({ messageId: "wa-c1" }) - .mockResolvedValueOnce({ messageId: "wa-c2" }); - const longText = "a".repeat(5000); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: { text: longText } as ReplyPayload, - deps: { sendWhatsApp }, - }; - const result = await whatsappOutbound.sendPayload!(ctx); - - expect(sendWhatsApp.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of sendWhatsApp.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(4000); - } - expect(result).toMatchObject({ channel: "whatsapp" }); + installSendPayloadContractSuite({ + channel: "whatsapp", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness, }); }); diff --git a/src/test-utils/send-payload-contract.ts b/src/test-utils/send-payload-contract.ts new file mode 100644 index 00000000000..5e78e406a74 --- /dev/null +++ b/src/test-utils/send-payload-contract.ts @@ -0,0 +1,138 @@ +import { expect, it, type Mock } from "vitest"; + +type PayloadLike = { + mediaUrl?: string; + mediaUrls?: string[]; + text?: string; +}; + +type SendResultLike = { + messageId: string; + [key: string]: unknown; +}; + +type ChunkingMode = + | { + longTextLength: number; + maxChunkLength: number; + mode: "split"; + } + | { + longTextLength: number; + mode: "passthrough"; + }; + +export function installSendPayloadContractSuite(params: { + channel: string; + chunking: ChunkingMode; + createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => { + run: () => Promise>; + sendMock: Mock; + to: string; + }; +}) { + it("text-only delegates to sendText", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "hello" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("single media delegates to sendMedia", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "cap", + expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel }); + }); + + it("multi-media iterates URLs with caption on first", async () => { + const { run, sendMock, to } = params.createHarness({ + payload: { + text: "caption", + mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + }, + sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], + }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock).toHaveBeenNthCalledWith( + 1, + to, + "caption", + expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), + ); + expect(sendMock).toHaveBeenNthCalledWith( + 2, + to, + "", + expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), + ); + expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); + }); + + it("empty payload returns no-op", async () => { + const { run, sendMock } = params.createHarness({ payload: {} }); + const result = await run(); + + expect(sendMock).not.toHaveBeenCalled(); + expect(result).toEqual({ channel: params.channel, messageId: "" }); + }); + + if (params.chunking.mode === "passthrough") { + it("text exceeding chunk limit is sent as-is when chunker is null", async () => { + const text = "a".repeat(params.chunking.longTextLength); + const { run, sendMock, to } = params.createHarness({ payload: { text } }); + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); + expect(result).toMatchObject({ channel: params.channel }); + }); + return; + } + + const chunking = params.chunking; + + it("chunking splits long text", async () => { + const text = "a".repeat(chunking.longTextLength); + const { run, sendMock } = params.createHarness({ + payload: { text }, + sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], + }); + const result = await run(); + + expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); + for (const call of sendMock.mock.calls) { + expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); + } + expect(result).toMatchObject({ channel: params.channel }); + }); +} + +export function primeSendMock( + sendMock: Mock, + fallbackResult: Record, + sendResults: SendResultLike[] = [], +) { + sendMock.mockReset(); + if (sendResults.length === 0) { + sendMock.mockResolvedValue(fallbackResult); + return; + } + for (const result of sendResults) { + sendMock.mockResolvedValueOnce(result); + } +} From 9f5dee32f64f1628ebaadcf1e78a3a327cf0ddb2 Mon Sep 17 00:00:00 2001 From: David Guttman Date: Tue, 10 Mar 2026 13:42:15 -0700 Subject: [PATCH 27/31] fix(acp): implicit streamToParent for mode=run without thread (#42404) * fix(acp): implicit streamToParent for mode=run without thread When spawning ACP sessions with mode=run and no thread binding, automatically route output to parent session instead of Discord. This enables agent-to-agent supervision patterns where the spawning agent wants results returned programmatically, not posted as chat. The change makes sessions_spawn with runtime=acp and thread=false behave like direct acpx invocation - output goes to the spawning session, not to Discord. Fixes the issue where mode=run without thread still posted to Discord because hasDeliveryTarget was true when called from a Discord context. * fix: use resolved spawnMode instead of params.mode Move implicit streamToParent check to after resolveSpawnMode so that both explicit mode="run" and omitted mode (which defaults to "run" when thread is false) correctly trigger parent routing. This fixes the issue where callers that rely on default mode selection would not get the intended parent streaming behavior. * fix: tighten implicit ACP parent relay gating (#42404) (thanks @davidguttman) --------- Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/acp-spawn-parent-stream.ts | 4 +- src/agents/acp-spawn.test.ts | 297 ++++++++++++++++++++++++++ src/agents/acp-spawn.ts | 159 +++++++++++++- src/infra/heartbeat-reason.test.ts | 2 + src/infra/heartbeat-reason.ts | 3 + src/infra/heartbeat-runner.ts | 24 ++- src/infra/heartbeat-wake.ts | 10 + 8 files changed, 483 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6f3c5cd46..71f034fc489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant. - Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. - Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu. +- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman. ## 2026.3.8 diff --git a/src/agents/acp-spawn-parent-stream.ts b/src/agents/acp-spawn-parent-stream.ts index 94f04ce3940..36b113386c2 100644 --- a/src/agents/acp-spawn-parent-stream.ts +++ b/src/agents/acp-spawn-parent-stream.ts @@ -180,7 +180,9 @@ export function startAcpSpawnParentStreamRelay(params: { }; const wake = () => { requestHeartbeatNow( - scopedHeartbeatWakeOptions(parentSessionKey, { reason: "acp:spawn:stream" }), + scopedHeartbeatWakeOptions(parentSessionKey, { + reason: "acp:spawn:stream", + }), ); }; const emit = (text: string, contextKey: string) => { diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 0f28b709792..c53584cdf55 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -38,6 +38,7 @@ const hoisted = vi.hoisted(() => { const loadSessionStoreMock = vi.fn(); const resolveStorePathMock = vi.fn(); const resolveSessionTranscriptFileMock = vi.fn(); + const areHeartbeatsEnabledMock = vi.fn(); const state = { cfg: createDefaultSpawnConfig(), }; @@ -55,6 +56,7 @@ const hoisted = vi.hoisted(() => { loadSessionStoreMock, resolveStorePathMock, resolveSessionTranscriptFileMock, + areHeartbeatsEnabledMock, state, }; }); @@ -128,6 +130,14 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) = }; }); +vi.mock("../infra/heartbeat-wake.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + areHeartbeatsEnabled: () => hoisted.areHeartbeatsEnabledMock(), + }; +}); + vi.mock("./acp-spawn-parent-stream.js", () => ({ startAcpSpawnParentStreamRelay: (...args: unknown[]) => hoisted.startAcpSpawnParentStreamRelayMock(...args), @@ -192,6 +202,7 @@ function expectResolvedIntroTextInBindMetadata(): void { describe("spawnAcpDirect", () => { beforeEach(() => { hoisted.state.cfg = createDefaultSpawnConfig(); + hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true); hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { method?: string }; @@ -393,6 +404,8 @@ describe("spawnAcpDirect", () => { expect(result.status).toBe("accepted"); expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith( expect.objectContaining({ sessionId: "sess-123", @@ -633,6 +646,290 @@ describe("spawnAcpDirect", () => { expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); }); + it("implicitly streams mode=run ACP spawns for subagent requester sessions", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + }, + }, + }, + }; + const firstHandle = createRelayHandle(); + const secondHandle = createRelayHandle(); + hoisted.startAcpSpawnParentStreamRelayMock + .mockReset() + .mockReturnValueOnce(firstHandle) + .mockReturnValueOnce(secondHandle); + hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { + const store: Record< + string, + { sessionId: string; updatedAt: number; deliveryContext?: unknown } + > = { + "agent:main:subagent:parent": { + sessionId: "parent-sess-1", + updatedAt: Date.now(), + deliveryContext: { + channel: "discord", + to: "channel:parent-channel", + accountId: "default", + }, + }, + }; + return new Proxy(store, { + get(target, prop) { + if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { + return { sessionId: "sess-123", updatedAt: Date.now() }; + } + return target[prop as keyof typeof target]; + }, + }); + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:parent", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBe("/tmp/sess-main.acp-stream.jsonl"); + const agentCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "agent"); + expect(agentCall?.params?.deliver).toBe(false); + expect(agentCall?.params?.channel).toBeUndefined(); + expect(agentCall?.params?.to).toBeUndefined(); + expect(agentCall?.params?.threadId).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionKey: "agent:main:subagent:parent", + agentId: "codex", + logPath: "/tmp/sess-main.acp-stream.jsonl", + emitStartNotice: false, + }), + ); + expect(firstHandle.dispose).toHaveBeenCalledTimes(1); + expect(secondHandle.notifyStarted).toHaveBeenCalledTimes(1); + }); + + it("does not implicitly stream when heartbeat target is not session-local", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "discord", + to: "channel:ops-room", + }, + }, + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:fixed-target", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream when session scope is global", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + session: { + ...hoisted.state.cfg.session, + scope: "global", + }, + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + }, + }, + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:global-scope", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for subagent requester sessions when heartbeat is disabled", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }], + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:research:subagent:orchestrator", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for subagent requester sessions when heartbeat cadence is invalid", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + agents: { + list: [ + { + id: "research", + heartbeat: { every: "0m" }, + }, + ], + }, + }; + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:research:subagent:invalid-heartbeat", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream when heartbeats are runtime-disabled", async () => { + hoisted.areHeartbeatsEnabledMock.mockReturnValue(false); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:runtime-disabled", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for legacy subagent requester session keys", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "subagent:legacy-worker", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for subagent requester sessions with thread context", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:thread-context", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + agentThreadId: "requester-thread", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + + it("does not implicitly stream for thread-bound subagent requester sessions", async () => { + hoisted.sessionBindingListBySessionMock.mockImplementation((targetSessionKey: string) => { + if (targetSessionKey === "agent:main:subagent:thread-bound") { + return [ + createSessionBinding({ + targetSessionKey, + targetKind: "subagent", + status: "active", + }), + ]; + } + return []; + }); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + }, + { + agentSessionKey: "agent:main:subagent:thread-bound", + agentChannel: "discord", + agentAccountId: "default", + agentTo: "channel:parent-channel", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.mode).toBe("run"); + expect(result.streamLogPath).toBeUndefined(); + expect(hoisted.startAcpSpawnParentStreamRelayMock).not.toHaveBeenCalled(); + }); + it("announces parent relay start only after successful child dispatch", async () => { const firstHandle = createRelayHandle(); const secondHandle = createRelayHandle(); diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 5d305b25f27..9d68a234aea 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -10,6 +10,7 @@ import { resolveAcpThreadSessionDetailLines, } from "../acp/runtime/session-identifiers.js"; import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js"; +import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -21,11 +22,13 @@ import { resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, } from "../channels/thread-bindings-policy.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js"; import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import { callGateway } from "../gateway/call.js"; +import { areHeartbeatsEnabled } from "../infra/heartbeat-wake.js"; import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import { getSessionBindingService, @@ -33,13 +36,18 @@ import { type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeAgentId } from "../routing/session-key.js"; -import { normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { + isSubagentSessionKey, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; +import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js"; import { type AcpSpawnParentRelayHandle, resolveAcpSpawnStreamLogPath, startAcpSpawnParentStreamRelay, } from "./acp-spawn-parent-stream.js"; +import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; @@ -130,6 +138,95 @@ function resolveAcpSessionMode(mode: SpawnAcpMode): AcpRuntimeSessionMode { return mode === "session" ? "persistent" : "oneshot"; } +function isHeartbeatEnabledForSessionAgent(params: { + cfg: OpenClawConfig; + sessionKey?: string; +}): boolean { + if (!areHeartbeatsEnabled()) { + return false; + } + const requesterAgentId = parseAgentSessionKey(params.sessionKey)?.agentId; + if (!requesterAgentId) { + return true; + } + + const agentEntries = params.cfg.agents?.list ?? []; + const hasExplicitHeartbeatAgents = agentEntries.some((entry) => Boolean(entry?.heartbeat)); + const enabledByPolicy = hasExplicitHeartbeatAgents + ? agentEntries.some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === requesterAgentId, + ) + : requesterAgentId === resolveDefaultAgentId(params.cfg); + if (!enabledByPolicy) { + return false; + } + + const heartbeatEvery = + resolveAgentConfig(params.cfg, requesterAgentId)?.heartbeat?.every ?? + params.cfg.agents?.defaults?.heartbeat?.every ?? + DEFAULT_HEARTBEAT_EVERY; + const trimmedEvery = typeof heartbeatEvery === "string" ? heartbeatEvery.trim() : ""; + if (!trimmedEvery) { + return false; + } + try { + return parseDurationMs(trimmedEvery, { defaultUnit: "m" }) > 0; + } catch { + return false; + } +} + +function resolveHeartbeatConfigForAgent(params: { + cfg: OpenClawConfig; + agentId: string; +}): NonNullable["defaults"]>["heartbeat"] { + const defaults = params.cfg.agents?.defaults?.heartbeat; + const overrides = resolveAgentConfig(params.cfg, params.agentId)?.heartbeat; + if (!defaults && !overrides) { + return undefined; + } + return { + ...defaults, + ...overrides, + }; +} + +function hasSessionLocalHeartbeatRelayRoute(params: { + cfg: OpenClawConfig; + parentSessionKey: string; + requesterAgentId: string; +}): boolean { + const scope = params.cfg.session?.scope ?? "per-sender"; + if (scope === "global") { + return false; + } + + const heartbeat = resolveHeartbeatConfigForAgent({ + cfg: params.cfg, + agentId: params.requesterAgentId, + }); + if ((heartbeat?.target ?? "none") !== "last") { + return false; + } + + // Explicit delivery overrides are not session-local and can route updates + // to unrelated destinations (for example a pinned ops channel). + if (typeof heartbeat?.to === "string" && heartbeat.to.trim().length > 0) { + return false; + } + if (typeof heartbeat?.accountId === "string" && heartbeat.accountId.trim().length > 0) { + return false; + } + + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: params.requesterAgentId, + }); + const sessionStore = loadSessionStore(storePath); + const parentEntry = sessionStore[params.parentSessionKey]; + const parentDeliveryContext = deliveryContextFromSession(parentEntry); + return Boolean(parentDeliveryContext?.channel && parentDeliveryContext.to); +} + function resolveTargetAcpAgentId(params: { requestedAgentId?: string; cfg: OpenClawConfig; @@ -326,6 +423,8 @@ export async function spawnAcpDirect( error: 'sessions_spawn streamTo="parent" requires an active requester session context.', }; } + + const requestThreadBinding = params.thread === true; const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({ cfg, requesterSessionKey: ctx.agentSessionKey, @@ -339,7 +438,6 @@ export async function spawnAcpDirect( }; } - const requestThreadBinding = params.thread === true; const spawnMode = resolveSpawnMode({ requestedMode: params.mode, threadRequested: requestThreadBinding, @@ -351,6 +449,52 @@ export async function spawnAcpDirect( }; } + const bindingService = getSessionBindingService(); + const requesterParsedSession = parseAgentSessionKey(parentSessionKey); + const requesterIsSubagentSession = + Boolean(requesterParsedSession) && isSubagentSessionKey(parentSessionKey); + const requesterHasActiveSubagentBinding = + requesterIsSubagentSession && parentSessionKey + ? bindingService + .listBySession(parentSessionKey) + .some((record) => record.targetKind === "subagent" && record.status !== "ended") + : false; + const requesterHasThreadContext = + typeof ctx.agentThreadId === "string" + ? ctx.agentThreadId.trim().length > 0 + : ctx.agentThreadId != null; + const requesterHeartbeatEnabled = isHeartbeatEnabledForSessionAgent({ + cfg, + sessionKey: parentSessionKey, + }); + const requesterAgentId = requesterParsedSession?.agentId; + const requesterHeartbeatRelayRouteUsable = + parentSessionKey && requesterAgentId + ? hasSessionLocalHeartbeatRelayRoute({ + cfg, + parentSessionKey, + requesterAgentId, + }) + : false; + + // For mode=run without thread binding, implicitly route output to parent + // only for spawned subagent orchestrator sessions with heartbeat enabled + // AND a session-local heartbeat delivery route (target=last + usable last route). + // Skip requester sessions that are thread-bound (or carrying thread context) + // so user-facing threads do not receive unsolicited ACP progress chatter + // unless streamTo="parent" is explicitly requested. Use resolved spawnMode + // (not params.mode) so default mode selection works. + const implicitStreamToParent = + !streamToParentRequested && + spawnMode === "run" && + !requestThreadBinding && + requesterIsSubagentSession && + !requesterHasActiveSubagentBinding && + !requesterHasThreadContext && + requesterHeartbeatEnabled && + requesterHeartbeatRelayRouteUsable; + const effectiveStreamToParent = streamToParentRequested || implicitStreamToParent; + const targetAgentResult = resolveTargetAcpAgentId({ requestedAgentId: params.agentId, cfg, @@ -392,7 +536,6 @@ export async function spawnAcpDirect( } const acpManager = getAcpSessionManager(); - const bindingService = getSessionBindingService(); let binding: SessionBindingRecord | null = null; let sessionCreated = false; let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined; @@ -530,17 +673,17 @@ export async function spawnAcpDirect( // Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers // decide how to relay status. Inline delivery is reserved for thread-bound sessions. const useInlineDelivery = - hasDeliveryTarget && spawnMode === "session" && !streamToParentRequested; + hasDeliveryTarget && spawnMode === "session" && !effectiveStreamToParent; const childIdem = crypto.randomUUID(); let childRunId: string = childIdem; const streamLogPath = - streamToParentRequested && parentSessionKey + effectiveStreamToParent && parentSessionKey ? resolveAcpSpawnStreamLogPath({ childSessionKey: sessionKey, }) : undefined; let parentRelay: AcpSpawnParentRelayHandle | undefined; - if (streamToParentRequested && parentSessionKey) { + if (effectiveStreamToParent && parentSessionKey) { // Register relay before dispatch so fast lifecycle failures are not missed. parentRelay = startAcpSpawnParentStreamRelay({ runId: childIdem, @@ -585,7 +728,7 @@ export async function spawnAcpDirect( }; } - if (streamToParentRequested && parentSessionKey) { + if (effectiveStreamToParent && parentSessionKey) { if (parentRelay && childRunId !== childIdem) { parentRelay.dispose(); // Defensive fallback if gateway returns a runId that differs from idempotency key. diff --git a/src/infra/heartbeat-reason.test.ts b/src/infra/heartbeat-reason.test.ts index 6c2fdc68f97..69d23e3219d 100644 --- a/src/infra/heartbeat-reason.test.ts +++ b/src/infra/heartbeat-reason.test.ts @@ -19,6 +19,7 @@ describe("heartbeat-reason", () => { expect(resolveHeartbeatReasonKind("manual")).toBe("manual"); expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event"); expect(resolveHeartbeatReasonKind("wake")).toBe("wake"); + expect(resolveHeartbeatReasonKind("acp:spawn:stream")).toBe("wake"); expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron"); expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook"); expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook"); @@ -35,6 +36,7 @@ describe("heartbeat-reason", () => { expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true); expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true); expect(isHeartbeatEventDrivenReason("wake")).toBe(true); + expect(isHeartbeatEventDrivenReason("acp:spawn:stream")).toBe(true); expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true); expect(isHeartbeatEventDrivenReason("interval")).toBe(false); expect(isHeartbeatEventDrivenReason("manual")).toBe(false); diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts index 968b1e24062..447ca733e53 100644 --- a/src/infra/heartbeat-reason.ts +++ b/src/infra/heartbeat-reason.ts @@ -34,6 +34,9 @@ export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind if (trimmed === "wake") { return "wake"; } + if (trimmed.startsWith("acp:spawn:")) { + return "wake"; + } if (trimmed.startsWith("cron:")) { return "cron"; } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c3c58d34c1e..344fd22d8fc 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -38,7 +38,11 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; -import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; +import { + normalizeAgentId, + parseAgentSessionKey, + toAgentStoreSessionKey, +} from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { escapeRegExp } from "../utils.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; @@ -53,9 +57,11 @@ import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js" import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { + areHeartbeatsEnabled, type HeartbeatRunResult, type HeartbeatWakeHandler, requestHeartbeatNow, + setHeartbeatsEnabled, setHeartbeatWakeHandler, } from "./heartbeat-wake.js"; import type { OutboundSendDeps } from "./outbound/deliver.js"; @@ -75,11 +81,8 @@ export type HeartbeatDeps = OutboundSendDeps & }; const log = createSubsystemLogger("gateway/heartbeat"); -let heartbeatsEnabled = true; -export function setHeartbeatsEnabled(enabled: boolean) { - heartbeatsEnabled = enabled; -} +export { areHeartbeatsEnabled, setHeartbeatsEnabled }; type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; type HeartbeatAgent = { @@ -611,9 +614,14 @@ export async function runHeartbeatOnce(opts: { deps?: HeartbeatDeps; }): Promise { const cfg = opts.cfg ?? loadConfig(); - const agentId = normalizeAgentId(opts.agentId ?? resolveDefaultAgentId(cfg)); + const explicitAgentId = typeof opts.agentId === "string" ? opts.agentId.trim() : ""; + const forcedSessionAgentId = + explicitAgentId.length > 0 ? undefined : parseAgentSessionKey(opts.sessionKey)?.agentId; + const agentId = normalizeAgentId( + explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg), + ); const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId); - if (!heartbeatsEnabled) { + if (!areHeartbeatsEnabled()) { return { status: "skipped", reason: "disabled" }; } if (!isHeartbeatEnabledForAgent(cfg, agentId)) { @@ -1114,7 +1122,7 @@ export function startHeartbeatRunner(opts: { reason: "disabled", } satisfies HeartbeatRunResult; } - if (!heartbeatsEnabled) { + if (!areHeartbeatsEnabled()) { return { status: "skipped", reason: "disabled", diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index bccfdfe9829..3aaaca5ed96 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -15,6 +15,16 @@ export type HeartbeatWakeHandler = (opts: { sessionKey?: string; }) => Promise; +let heartbeatsEnabled = true; + +export function setHeartbeatsEnabled(enabled: boolean) { + heartbeatsEnabled = enabled; +} + +export function areHeartbeatsEnabled(): boolean { + return heartbeatsEnabled; +} + type WakeTimerKind = "normal" | "retry"; type PendingWakeReason = { reason: string; From b16ee34c34a14ad1a3fc1b44fd76fc3c1f71c0f9 Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 10 Mar 2026 15:58:18 -0500 Subject: [PATCH 28/31] fix(ci): auto-close and lock r: spam items --- .github/workflows/auto-response.yml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 60e1707cf35..d9d810bffa7 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -393,6 +393,7 @@ jobs: } const invalidLabel = "invalid"; + const spamLabel = "r: spam"; const dirtyLabel = "dirty"; const noisyPrMessage = "Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch."; @@ -429,6 +430,21 @@ jobs: }); return; } + if (labelSet.has(spamLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + state: "closed", + }); + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + lock_reason: "spam", + }); + return; + } if (labelSet.has(invalidLabel)) { await github.rest.issues.update({ owner: context.repo.owner, @@ -440,6 +456,23 @@ jobs: } } + if (issue && labelSet.has(spamLabel)) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }); + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + lock_reason: "spam", + }); + return; + } + if (issue && labelSet.has(invalidLabel)) { await github.rest.issues.update({ owner: context.repo.owner, From 0c17e7c225bdd26cefbda6c7691356ba61d47e8d Mon Sep 17 00:00:00 2001 From: Shadow Date: Tue, 10 Mar 2026 16:00:34 -0500 Subject: [PATCH 29/31] docs: document r: spam auto-close label --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 80443603c87..69b0df68faa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ - `r: testflight`: close requests asking for TestFlight access/builds. OpenClaw does not provide TestFlight distribution yet, so use the standard response (“Not available, build from source.”) instead of ad-hoc replies. - `r: third-party-extension`: close with guidance to ship as third-party plugin. - `r: moltbook`: close + lock as off-topic (not affiliated). +- `r: spam`: close + lock as spam (`lock_reason: spam`). - `invalid`: close invalid items (issues are closed as `not_planned`; PRs are closed). - `dirty`: close PRs with too many unrelated/unexpected changes (PR-only label). From 53374394fbbb1e074021734d7d28ec0d50e9a153 Mon Sep 17 00:00:00 2001 From: PonyX-lab Date: Wed, 11 Mar 2026 05:02:43 +0800 Subject: [PATCH 30/31] Fix stale runtime model reuse on session reset (#41173) Merged via squash. Prepared head SHA: d8a04a466a3b110aa7d608cc1425a66fa65e326b Co-authored-by: PonyX-lab <266766228+PonyX-lab@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../agent-runner.runreplyagent.e2e.test.ts | 73 +++++++++++++++++++ src/auto-reply/reply/agent-runner.ts | 4 + src/gateway/server-methods/sessions.ts | 18 ++++- ...sessions.gateway-server-sessions-a.test.ts | 37 ++++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f034fc489..60df48c6357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf. - Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu. - ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman. +- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab. ## 2026.3.8 diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 599a8fd6a48..6bebdc6a390 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1255,6 +1255,79 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); + it("clears stale runtime model fields when resetSession retries after compaction failure", async () => { + await withTempStateDir(async (stateDir) => { + const sessionId = "session-stale-model"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry: SessionEntry = { + sessionId, + updatedAt: Date.now(), + sessionFile: transcriptPath, + modelProvider: "qwencode", + model: "qwen3.5-plus-2026-02-15", + contextTokens: 123456, + systemPromptReport: { + source: "run", + generatedAt: Date.now(), + sessionId, + sessionKey: "main", + provider: "qwencode", + model: "qwen3.5-plus-2026-02-15", + workspaceDir: stateDir, + bootstrapMaxChars: 1000, + bootstrapTotalMaxChars: 2000, + systemPrompt: { + chars: 10, + projectContextChars: 5, + nonProjectContextChars: 5, + }, + injectedWorkspaceFiles: [], + skills: { + promptChars: 0, + entries: [], + }, + tools: { + listChars: 0, + schemaChars: 0, + entries: [], + }, + }, + }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ); + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + await run(); + + expect(sessionStore.main.modelProvider).toBeUndefined(); + expect(sessionStore.main.model).toBeUndefined(); + expect(sessionStore.main.contextTokens).toBeUndefined(); + expect(sessionStore.main.systemPromptReport).toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.modelProvider).toBeUndefined(); + expect(persisted.main.model).toBeUndefined(); + expect(persisted.main.contextTokens).toBeUndefined(); + expect(persisted.main.systemPromptReport).toBeUndefined(); + }); + }); + it("surfaces overflow fallback when embedded run returns empty payloads", async () => { state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ payloads: [], diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b6dcd7dcd91..edc441a2552 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -278,6 +278,10 @@ export async function runReplyAgent(params: { updatedAt: Date.now(), systemSent: false, abortedLastRun: false, + modelProvider: undefined, + model: undefined, + contextTokens: undefined, + systemPromptReport: undefined, fallbackNoticeSelectedModel: undefined, fallbackNoticeActiveModel: undefined, fallbackNoticeReason: undefined, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index bd8f6b57ac2..83bf3057278 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -128,6 +128,19 @@ function migrateAndPruneSessionStoreKey(params: { return { target, primaryKey, entry: params.store[primaryKey] }; } +function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined { + if (!entry) { + return entry; + } + return { + ...entry, + model: undefined, + modelProvider: undefined, + contextTokens: undefined, + systemPromptReport: undefined, + }; +} + function archiveSessionTranscriptsForSession(params: { sessionId: string | undefined; storePath: string; @@ -507,9 +520,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { const next = await updateSessionStore(storePath, (store) => { const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); const entry = store[primaryKey]; + const resetEntry = stripRuntimeModelState(entry); const parsed = parseAgentSessionKey(primaryKey); const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); - const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId); + const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId); oldSessionId = entry?.sessionId; oldSessionFile = entry?.sessionFile; const now = Date.now(); @@ -524,7 +538,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { responseUsage: entry?.responseUsage, model: resolvedModel.model, modelProvider: resolvedModel.provider, - contextTokens: entry?.contextTokens, + contextTokens: resetEntry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, origin: snapshotSessionOrigin(entry), diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index f986d49c648..1decc4b9178 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -591,6 +591,43 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.reset recomputes model from defaults instead of stale runtime model", async () => { + await createSessionStoreDir(); + testState.agentConfig = { + model: { + primary: "openai/gpt-test-a", + }, + }; + + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-stale-model", + updatedAt: Date.now(), + modelProvider: "qwencode", + model: "qwen3.5-plus-2026-02-15", + contextTokens: 123456, + }, + }, + }); + + const { ws } = await openClient(); + const reset = await rpcReq<{ + ok: true; + key: string; + entry: { sessionId: string; modelProvider?: string; model?: string; contextTokens?: number }; + }>(ws, "sessions.reset", { key: "main" }); + + expect(reset.ok).toBe(true); + expect(reset.payload?.key).toBe("agent:main:main"); + expect(reset.payload?.entry.sessionId).not.toBe("sess-stale-model"); + expect(reset.payload?.entry.modelProvider).toBe("openai"); + expect(reset.payload?.entry.model).toBe("gpt-test-a"); + expect(reset.payload?.entry.contextTokens).toBeUndefined(); + + ws.close(); + }); + test("sessions.preview resolves legacy mixed-case main alias with custom mainKey", async () => { const { dir, storePath } = await createSessionStoreDir(); testState.agentsConfig = { list: [{ id: "ops", default: true }] }; From c00117aff2ed49422be9b1b9fe136d2774a33643 Mon Sep 17 00:00:00 2001 From: Onur Date: Tue, 10 Mar 2026 22:15:00 +0100 Subject: [PATCH 31/31] docs: require codex review in contributing guide (#42503) --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b9fe8fa98f..c7808db9cf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,7 @@ Welcome to the lobster tank! 🦞 - Test locally with your OpenClaw instance - Run tests: `pnpm build && pnpm check && pnpm test` +- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why @@ -99,6 +100,8 @@ If a review bot leaves review conversations on your PR, you are expected to hand - Resolve the conversation yourself once the code or explanation fully addresses the bot's concern - Reply and leave it open only when you need maintainer or reviewer judgment - Do not leave "fixed" bot review conversations for maintainers to clean up for you +- If Codex leaves comments, address every relevant one or resolve it with a short explanation when it is not applicable to your change +- If GitHub Codex review does not trigger for some reason, run `codex review --base origin/main` locally anyway and treat that output as required review work This applies to both human-authored and AI-assisted PRs. @@ -127,6 +130,7 @@ Please include in your PR: - [ ] Note the degree of testing (untested / lightly tested / fully tested) - [ ] Include prompts or session logs if possible (super helpful!) - [ ] Confirm you understand what the code does +- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review - [ ] Resolve or reply to bot review conversations after you address them AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.