diff --git a/.secrets.baseline b/.secrets.baseline index 8066ff84714..0df5fc0b733 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -9777,35 +9777,35 @@ "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 2039 + "line_number": 2041 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 2271 + "line_number": 2273 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 2399 + "line_number": 2401 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 2652 + "line_number": 2654 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-reference.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 2654 + "line_number": 2656 } ], "docs/gateway/configuration.md": [ @@ -12076,7 +12076,7 @@ "filename": "src/agents/model-auth.ts", "hashed_secret": "8956265d216d474a080edaa97880d37fc1386f33", "is_verified": false, - "line_number": 25 + "line_number": 27 } ], "src/agents/model-fallback.run-embedded.e2e.test.ts": [ @@ -12110,28 +12110,28 @@ "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", "hashed_secret": "2a9da819718779deba96d5aee1d1f4948047c2bd", "is_verified": false, - "line_number": 46 + "line_number": 47 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", "hashed_secret": "fa9144b340ea7886885669e2e7a808c86ee14a07", "is_verified": false, - "line_number": 117 + "line_number": 118 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", "hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7", "is_verified": false, - "line_number": 181 + "line_number": 182 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", "hashed_secret": "565a8d87240aae631d7a901c1f697d46ee141a7b", "is_verified": false, - "line_number": 214 + "line_number": 215 } ], "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": [ @@ -12158,7 +12158,7 @@ "filename": "src/agents/models-config.providers.kilocode.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 24 + "line_number": 14 } ], "src/agents/models-config.providers.kimi-coding.test.ts": [ @@ -12176,21 +12176,21 @@ "filename": "src/agents/models-config.providers.normalize-keys.test.ts", "hashed_secret": "ba4d38e2a7e8c718913887136d2526351d05cd69", "is_verified": false, - "line_number": 16 + "line_number": 17 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.providers.normalize-keys.test.ts", "hashed_secret": "02ecb94373bfb3dfe827ca18409f50b016e8302a", "is_verified": false, - "line_number": 46 + "line_number": 47 }, { "type": "Secret Keyword", "filename": "src/agents/models-config.providers.normalize-keys.test.ts", "hashed_secret": "b9cdfe69a75e4f2491bcbaf1934ab5e4fd69eb6b", "is_verified": false, - "line_number": 52 + "line_number": 53 } ], "src/agents/models-config.providers.nvidia.test.ts": [ @@ -12324,7 +12324,7 @@ "filename": "src/agents/pi-embedded-runner/model.ts", "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", "is_verified": false, - "line_number": 232 + "line_number": 267 } ], "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ @@ -12342,21 +12342,21 @@ "filename": "src/agents/pi-extensions/compaction-safeguard.test.ts", "hashed_secret": "0091061a3babbe6f11d48aa0142e22341b3ea446", "is_verified": false, - "line_number": 665 + "line_number": 700 }, { "type": "Hex High Entropy String", "filename": "src/agents/pi-extensions/compaction-safeguard.test.ts", "hashed_secret": "ef678205593788329ff416ce5c65fa04f33a05bd", "is_verified": false, - "line_number": 811 + "line_number": 846 }, { "type": "Secret Keyword", "filename": "src/agents/pi-extensions/compaction-safeguard.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 1490 + "line_number": 1525 } ], "src/agents/sandbox/browser.novnc-url.test.ts": [ @@ -13008,7 +13008,7 @@ "filename": "src/commands/message.test.ts", "hashed_secret": "3bb1ec510d35ab2af7d05d8bbd5f0820333f1a0d", "is_verified": false, - "line_number": 194 + "line_number": 193 } ], "src/commands/model-picker.test.ts": [ @@ -13026,14 +13026,14 @@ "filename": "src/commands/onboard-auth.config-core.kilocode.test.ts", "hashed_secret": "01800a0712a2a1aa928b95c4745e9ee06673925b", "is_verified": false, - "line_number": 163 + "line_number": 153 }, { "type": "Secret Keyword", "filename": "src/commands/onboard-auth.config-core.kilocode.test.ts", "hashed_secret": "8d2ce71c6723bf46f6c166984b4ddb597f92322a", "is_verified": false, - "line_number": 190 + "line_number": 180 } ], "src/commands/onboard-auth.config-minimax.ts": [ @@ -14036,21 +14036,21 @@ "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", "hashed_secret": "45c7365e3b542cdb4fae6ec10c2ff149224d7656", "is_verified": false, - "line_number": 123 + "line_number": 124 }, { "type": "Secret Keyword", "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", "hashed_secret": "b67074884ab7ef7c7a8cd6a3da9565d96c792248", "is_verified": false, - "line_number": 124 + "line_number": 125 }, { "type": "Secret Keyword", "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", "hashed_secret": "d4d8027e64f9cf4180d3aecfe31ea409368022ee", "is_verified": false, - "line_number": 125 + "line_number": 126 } ], "src/infra/push-apns.test.ts": [ @@ -14114,14 +14114,14 @@ "filename": "src/line/bot-handlers.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 107 + "line_number": 101 }, { "type": "Secret Keyword", "filename": "src/line/bot-handlers.test.ts", "hashed_secret": "d76baddf1b9e3d8e31216f22c73d65d2e91ada7b", "is_verified": false, - "line_number": 358 + "line_number": 399 } ], "src/line/bot-message-context.test.ts": [ @@ -14408,28 +14408,28 @@ "filename": "src/secrets/apply.test.ts", "hashed_secret": "bb0a04dd3612988998c812bc3ad580ba0fb9d905", "is_verified": false, - "line_number": 360 + "line_number": 372 }, { "type": "Secret Keyword", "filename": "src/secrets/apply.test.ts", "hashed_secret": "942c7142a36b069509b957db07321a1cb9b2123a", "is_verified": false, - "line_number": 397 + "line_number": 409 }, { "type": "Secret Keyword", "filename": "src/secrets/apply.test.ts", "hashed_secret": "9c0faa509a7c3079f58421307ecbcaceb7cbd545", "is_verified": false, - "line_number": 450 + "line_number": 503 }, { "type": "Secret Keyword", "filename": "src/secrets/apply.test.ts", "hashed_secret": "c9a4d024f4386d3a4b044de8cb52226383591481", "is_verified": false, - "line_number": 483 + "line_number": 536 } ], "src/secrets/command-config.test.ts": [ @@ -14725,5 +14725,5 @@ } ] }, - "generated_at": "2026-03-07T11:12:54Z" + "generated_at": "2026-03-07T17:40:40Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 32777b95065..a908213a3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/local setup: default unset local `tools.profile` to `coding` instead of `messaging`, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek. - Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464) - Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web. - Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i. @@ -109,6 +110,7 @@ Docs: https://docs.openclaw.ai - TUI/session-key canonicalization: normalize `openclaw tui --session` values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc. - iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth. - Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant. +- Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant. - Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera. - Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr. - ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob. @@ -239,6 +241,8 @@ Docs: https://docs.openclaw.ai - Config/compaction safeguard settings: regression-test `agents.defaults.compaction.recentTurnsPreserve` through `loadConfig()` and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz. - iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman. - CLI/Docs memory help accuracy: clarify `openclaw memory status --deep` behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974. +- Auto-reply/allowlist store account scoping: keep `/allowlist ... --store` writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix. +- Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (`x-forwarded-for` / `x-real-ip`) and rejecting `sec-fetch-site: cross-site`; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts. ## 2026.3.2 @@ -597,6 +601,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Models/provider config precedence: prefer exact `models.providers.` matches before normalized provider aliases in embedded model resolution, preventing alias/canonical key collisions from applying the wrong provider `api`, `baseUrl`, or headers. (#35934) thanks @RealKai42. +- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting. - Logging/Subsystem console timestamps: route subsystem console timestamp rendering through `formatConsoleTimestamp(...)` so `pretty` and timestamp-prefix output use local timezone formatting consistently instead of inline UTC `toISOString()` paths. (#25970) Thanks @openperf. - Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff. - Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42. diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 942939bb591..83eb55b59aa 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -38,7 +38,7 @@ def maybe_decode_hex_keychain_secret(value) # `security find-generic-password -w` can return hex when the stored secret # includes newlines/non-printable bytes (like PEM files). - if decoded.include?("BEGIN PRIVATE KEY") || decoded.include?("END PRIVATE KEY") + if decoded.include?("BEGIN PRIVATE KEY") || decoded.include?("END PRIVATE KEY") # pragma: allowlist secret UI.message("Decoded hex-encoded ASC key content from Keychain.") return decoded end diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 0712a16661b..93c8d04b41a 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -22,3 +22,7 @@ openclaw agent --agent ops --message "Summarize logs" openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" ``` + +## Notes + +- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names or `secretref-managed`), not resolved secret plaintext. diff --git a/docs/cli/models.md b/docs/cli/models.md index 700b562c353..e023784cc5e 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -38,6 +38,7 @@ Notes: - `models set ` accepts `provider/model` or an alias. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, OpenClaw treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). +- `models status` may show `marker()` in auth output for non-secret placeholders (for example `OPENAI_API_KEY`, `secretref-managed`, `minimax-oauth`, `qwen-oauth`, `ollama-local`) instead of masking them as secrets. ### `models status` diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index db5e9476c55..f90a5de8ec0 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -14,7 +14,7 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot Command roles: - `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes). -- `audit`: read-only scan of configuration/auth stores and legacy residues for plaintext, unresolved refs, and precedence drift. +- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift. - `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required). - `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues. @@ -62,8 +62,13 @@ Scan OpenClaw state for: - plaintext secret storage - unresolved refs - precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs) +- generated `agents/*/agent/models.json` residues (provider `apiKey` values and sensitive provider headers) - legacy residues (legacy auth store entries, OAuth reminders) +Header residue note: + +- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). + ```bash openclaw secrets audit openclaw secrets audit --check diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 981bd95086c..2ad809d9599 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -212,6 +212,10 @@ is merged by default unless `models.mode` is set to `replace`. Merge mode precedence for matching provider IDs: -- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win. +- Non-empty `baseUrl` already present in the agent `models.json` wins. +- Non-empty `apiKey` in the agent `models.json` wins only when that provider is not SecretRef-managed in current config/auth-profile context. +- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`. - Other provider fields are refreshed from config and normalized catalog data. + +This marker-based persistence applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 749b0d2b261..249c35b7309 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1676,7 +1676,7 @@ Defaults for Talk mode (macOS/iOS/Android). `tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`: -Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved). +Local onboarding defaults new local configs to `tools.profile: "coding"` when unset (existing explicit profiles are preserved). | Profile | Includes | | ----------- | ----------------------------------------------------------------------------------------- | @@ -2004,7 +2004,9 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model - Use `authHeader: true` + `headers` for custom auth needs. - Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`). - Merge precedence for matching provider IDs: - - Non-empty agent `models.json` `apiKey`/`baseUrl` win. + - Non-empty agent `models.json` `baseUrl` values win. + - Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context. + - SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config. - Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values. - Use `models.mode: "replace"` when you want config to fully rewrite `models.json`. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 2956d53133e..3ef08267618 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -372,11 +372,16 @@ openclaw secrets audit --check Findings include: -- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`) +- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`, and generated `agents/*/agent/models.json`) +- plaintext sensitive provider header residues in generated `models.json` entries - unresolved refs - precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs) - legacy residues (`auth.json`, OAuth reminders) +Header residue note: + +- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). + ### `secrets configure` Interactive helper that: diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index d356e4f809e..dd1b5f1fd2f 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -23,6 +23,7 @@ Scope intent: [//]: # "secretref-supported-list-start" - `models.providers.*.apiKey` +- `models.providers.*.headers.*` - `skills.entries.*.apiKey` - `agents.defaults.memorySearch.remote.apiKey` - `agents.list[].memorySearch.remote.apiKey` @@ -98,6 +99,7 @@ Notes: - Auth-profile plan targets require `agentId`. - Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`). - Auth-profile refs are included in runtime resolution and audit coverage. +- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces. - For web search: - In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. - In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active. diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index ac454a605a6..773ef8ab162 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -426,6 +426,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "models.providers.*.headers.*", + "configFile": "openclaw.json", + "path": "models.providers.*.headers.*", + "secretShape": "secret_input", + "optIn": true + }, { "id": "skills.entries.*.apiKey", "configFile": "openclaw.json", diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index a6bacc5f2a1..2e7a43bdecc 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -276,7 +276,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 3a5c86c360e..3e3401cad64 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -34,7 +34,7 @@ Security trust model: - By default, OpenClaw is a personal agent: one trusted operator boundary. - Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). -- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in. +- Local onboarding now defaults new configs to `tools.profile: "coding"` so fresh local setups keep filesystem/runtime tools without forcing the unrestricted `full` profile. - If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index f9ff309be54..44f470ea73b 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -247,7 +247,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 874dc4bf514..ef1fc52b31a 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -51,7 +51,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) - - Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved) + - Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved) - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) diff --git a/extensions/bluebubbles/src/secret-input.ts b/extensions/bluebubbles/src/secret-input.ts index 8a5530f4607..a5aa73ebda0 100644 --- a/extensions/bluebubbles/src/secret-input.ts +++ b/extensions/bluebubbles/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/bluebubbles"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index b9a0fee6e59..5e8c2927691 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -135,6 +135,29 @@ describe("createDiffsHttpHandler", () => { 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", @@ -158,6 +181,30 @@ describe("createDiffsHttpHandler", () => { 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"); + }); + it("rate-limits repeated remote misses", async () => { const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); @@ -185,16 +232,26 @@ describe("createDiffsHttpHandler", () => { }); }); -function localReq(input: { method: string; url: string }): IncomingMessage { +function localReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "127.0.0.1" }, } as unknown as IncomingMessage; } -function remoteReq(input: { method: string; url: string }): IncomingMessage { +function remoteReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "203.0.113.10" }, } as unknown as IncomingMessage; } diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index 0f17e77fd9e..445500b2340 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -42,9 +42,8 @@ export function createDiffsHttpHandler(params: { return false; } - const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); - const localRequest = isLoopbackClientIp(remoteKey); - if (!localRequest && params.allowRemoteViewer !== true) { + const access = resolveViewerAccess(req); + if (!access.localRequest && params.allowRemoteViewer !== true) { respondText(res, 404, "Diff not found"); return true; } @@ -54,8 +53,8 @@ export function createDiffsHttpHandler(params: { return true; } - if (!localRequest) { - const throttled = viewerFailureLimiter.check(remoteKey); + if (!access.localRequest) { + const throttled = viewerFailureLimiter.check(access.remoteKey); if (!throttled.allowed) { res.statusCode = 429; setSharedHeaders(res, "text/plain; charset=utf-8"); @@ -74,27 +73,21 @@ export function createDiffsHttpHandler(params: { !DIFF_ARTIFACT_ID_PATTERN.test(id) || !DIFF_ARTIFACT_TOKEN_PATTERN.test(token) ) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); respondText(res, 404, "Diff not found"); return true; } const artifact = await params.store.getArtifact(id, token); if (!artifact) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); respondText(res, 404, "Diff not found or expired"); return true; } try { const html = await params.store.readHtml(id); - if (!localRequest) { - viewerFailureLimiter.reset(remoteKey); - } + resetRemoteFailures(viewerFailureLimiter, access); res.statusCode = 200; setSharedHeaders(res, "text/html; charset=utf-8"); res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY); @@ -105,9 +98,7 @@ export function createDiffsHttpHandler(params: { } return true; } catch (error) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`); respondText(res, 500, "Failed to load diff"); return true; @@ -184,6 +175,44 @@ function isLoopbackClientIp(clientIp: string): boolean { return clientIp === "127.0.0.1" || clientIp === "::1"; } +function hasProxyForwardingHints(req: IncomingMessage): boolean { + const headers = req.headers ?? {}; + return Boolean( + headers["x-forwarded-for"] || + headers["x-real-ip"] || + headers.forwarded || + headers["x-forwarded-host"] || + headers["x-forwarded-proto"], + ); +} + +function resolveViewerAccess(req: IncomingMessage): { + remoteKey: string; + localRequest: boolean; +} { + const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); + const localRequest = isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req); + return { remoteKey, localRequest }; +} + +function recordRemoteFailure( + limiter: ViewerFailureLimiter, + access: { remoteKey: string; localRequest: boolean }, +): void { + if (!access.localRequest) { + limiter.recordFailure(access.remoteKey); + } +} + +function resetRemoteFailures( + limiter: ViewerFailureLimiter, + access: { remoteKey: string; localRequest: boolean }, +): void { + if (!access.localRequest) { + limiter.reset(access.remoteKey); + } +} + type RateLimitCheckResult = { allowed: boolean; retryAfterMs: number; diff --git a/extensions/feishu/src/docx-batch-insert.test.ts b/extensions/feishu/src/docx-batch-insert.test.ts new file mode 100644 index 00000000000..239e46738b4 --- /dev/null +++ b/extensions/feishu/src/docx-batch-insert.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js"; + +function createCountingIterable(values: T[]) { + let iterations = 0; + return { + values: { + [Symbol.iterator]: function* () { + iterations += 1; + yield* values; + }, + }, + getIterations: () => iterations, + }; +} + +describe("insertBlocksInBatches", () => { + it("builds the source block map once for large flat trees", async () => { + const blockCount = BATCH_SIZE + 200; + const blocks = Array.from({ length: blockCount }, (_, index) => ({ + block_id: `block_${index}`, + block_type: 2, + })); + const counting = createCountingIterable(blocks); + const createMock = vi.fn(async ({ data }: { data: { children_id: string[] } }) => ({ + code: 0, + data: { + children: data.children_id.map((id) => ({ block_id: id })), + }, + })); + const client = { + docx: { + documentBlockDescendant: { + create: createMock, + }, + }, + } as any; + + const result = await insertBlocksInBatches( + client, + "doc_1", + counting.values as any[], + blocks.map((block) => block.block_id), + ); + + expect(counting.getIterations()).toBe(1); + expect(createMock).toHaveBeenCalledTimes(2); + expect(createMock.mock.calls[0]?.[0]?.data.children_id).toHaveLength(BATCH_SIZE); + expect(createMock.mock.calls[1]?.[0]?.data.children_id).toHaveLength(200); + expect(result.children).toHaveLength(blockCount); + }); + + it("keeps nested descendants grouped with their root blocks", async () => { + const createMock = vi.fn( + async ({ + data, + }: { + data: { children_id: string[]; descendants: Array<{ block_id: string }> }; + }) => ({ + code: 0, + data: { + children: data.children_id.map((id) => ({ block_id: id })), + }, + }), + ); + const client = { + docx: { + documentBlockDescendant: { + create: createMock, + }, + }, + } as any; + const blocks = [ + { block_id: "root_a", block_type: 1, children: ["child_a"] }, + { block_id: "child_a", block_type: 2 }, + { block_id: "root_b", block_type: 1, children: ["child_b"] }, + { block_id: "child_b", block_type: 2 }, + ]; + + await insertBlocksInBatches(client, "doc_1", blocks as any[], ["root_a", "root_b"]); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0]?.[0]?.data.children_id).toEqual(["root_a", "root_b"]); + expect( + createMock.mock.calls[0]?.[0]?.data.descendants.map( + (block: { block_id: string }) => block.block_id, + ), + ).toEqual(["root_a", "child_a", "root_b", "child_b"]); + }); +}); diff --git a/extensions/feishu/src/docx-batch-insert.ts b/extensions/feishu/src/docx-batch-insert.ts index e38552a4857..b855e53a4a9 100644 --- a/extensions/feishu/src/docx-batch-insert.ts +++ b/extensions/feishu/src/docx-batch-insert.ts @@ -14,16 +14,11 @@ export const BATCH_SIZE = 1000; // Feishu API limit per request type Logger = { info?: (msg: string) => void }; /** - * Collect all descendant blocks for a given set of first-level block IDs. + * Collect all descendant blocks for a given first-level block ID. * Recursively traverses the block tree to gather all children. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types -function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] { - const blockMap = new Map(); - for (const block of blocks) { - blockMap.set(block.block_id, block); - } - +function collectDescendants(blockMap: Map, rootId: string): any[] { const result: any[] = []; const visited = new Set(); @@ -47,9 +42,7 @@ function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] { } } - for (const id of firstLevelIds) { - collect(id); - } + collect(rootId); return result; } @@ -123,9 +116,13 @@ export async function insertBlocksInBatches( const batches: { firstLevelIds: string[]; blocks: any[] }[] = []; let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] }; const usedBlockIds = new Set(); + const blockMap = new Map(); + for (const block of blocks) { + blockMap.set(block.block_id, block); + } for (const firstLevelId of firstLevelBlockIds) { - const descendants = collectDescendants(blocks, [firstLevelId]); + const descendants = collectDescendants(blockMap, firstLevelId); const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id)); // A single block whose subtree exceeds the API limit cannot be split diff --git a/extensions/feishu/src/secret-input.ts b/extensions/feishu/src/secret-input.ts index a2c2f517f3a..37dda74f2eb 100644 --- a/extensions/feishu/src/secret-input.ts +++ b/extensions/feishu/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/feishu"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/matrix/src/secret-input.ts b/extensions/matrix/src/secret-input.ts index a5de1214773..c0827573480 100644 --- a/extensions/matrix/src/secret-input.ts +++ b/extensions/matrix/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/matrix"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts index 017109424bc..576f5b9fc45 100644 --- a/extensions/mattermost/src/secret-input.ts +++ b/extensions/mattermost/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/mattermost"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/nextcloud-talk/src/secret-input.ts b/extensions/nextcloud-talk/src/secret-input.ts index f51a0ad6872..d26cb8e4e23 100644 --- a/extensions/nextcloud-talk/src/secret-input.ts +++ b/extensions/nextcloud-talk/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/nextcloud-talk"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 7d5968a961d..8fb17c443f4 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -283,6 +283,36 @@ describe("nostr-profile-http", () => { expect(res._getStatusCode()).toBe(403); }); + it("rejects profile mutation with cross-site sec-fetch-site header", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { "sec-fetch-site": "cross-site" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + + it("rejects profile mutation when forwarded client ip is non-loopback", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("rejects private IP in picture URL (SSRF protection)", async () => { await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); @@ -431,6 +461,21 @@ describe("nostr-profile-http", () => { expect(res._getStatusCode()).toBe(403); }); + it("rejects import mutation when x-real-ip is non-loopback", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "POST", + "/api/channels/nostr/default/profile/import", + {}, + { headers: { "x-real-ip": "198.51.100.55" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("auto-merges when requested", async () => { const ctx = createMockContext({ getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }), diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index b4d53e16a4e..3dedf745125 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -224,6 +224,51 @@ function isLoopbackOriginLike(value: string): boolean { } } +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]; + } + return typeof value === "string" ? value : undefined; +} + +function normalizeIpCandidate(raw: string): string { + const unquoted = raw.trim().replace(/^"|"$/g, ""); + const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/); + if (bracketedWithOptionalPort) { + return bracketedWithOptionalPort[1] ?? ""; + } + const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/); + if (ipv4WithPort) { + return ipv4WithPort[1] ?? ""; + } + return unquoted; +} + +function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean { + const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]); + if (forwardedFor) { + for (const hop of forwardedFor.split(",")) { + const candidate = normalizeIpCandidate(hop); + if (!candidate) { + continue; + } + if (!isLoopbackRemoteAddress(candidate)) { + return true; + } + } + } + + const realIp = firstHeaderValue(req.headers["x-real-ip"]); + if (realIp) { + const candidate = normalizeIpCandidate(realIp); + if (candidate && !isLoopbackRemoteAddress(candidate)) { + return true; + } + } + + return false; +} + function enforceLoopbackMutationGuards( ctx: NostrProfileHttpContext, req: IncomingMessage, @@ -237,15 +282,30 @@ function enforceLoopbackMutationGuards( return false; } + // If a proxy exposes client-origin headers showing a non-loopback client, + // treat this as a remote request and deny mutation. + if (hasNonLoopbackForwardedClient(req)) { + ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers"); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + + const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase(); + if (secFetchSite === "cross-site") { + ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header"); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + // CSRF guard: browsers send Origin/Referer on cross-site requests. - const origin = req.headers.origin; + const origin = firstHeaderValue(req.headers.origin); if (typeof origin === "string" && !isLoopbackOriginLike(origin)) { ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`); sendJson(res, 403, { ok: false, error: "Forbidden" }); return false; } - const referer = req.headers.referer ?? req.headers.referrer; + const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer); if (typeof referer === "string" && !isLoopbackOriginLike(referer)) { ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`); sendJson(res, 403, { ok: false, error: "Forbidden" }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 81ef191ba77..d84516dbda5 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -282,7 +282,7 @@ export function createSynologyChatPlugin() { Surface: CHANNEL_ID, ConversationLabel: msg.senderName || msg.from, Timestamp: Date.now(), - CommandAuthorized: true, + CommandAuthorized: msg.commandAuthorized, }); // Dispatch via the SDK's buffered block dispatcher diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 2f6bd87788a..37ee566e6a6 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -237,6 +237,7 @@ describe("createWebhookHandler", () => { body: "Hello from json", from: "123", senderName: "json-user", + commandAuthorized: true, }), ); }); @@ -396,6 +397,7 @@ describe("createWebhookHandler", () => { senderName: "testuser", provider: "synology-chat", chatType: "direct", + commandAuthorized: true, }), ); }); @@ -422,6 +424,7 @@ describe("createWebhookHandler", () => { expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ body: expect.stringContaining("[FILTERED]"), + commandAuthorized: true, }), ); }); diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index fab4b9a0238..b4c73934db9 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -225,6 +225,7 @@ export interface WebhookHandlerDeps { chatType: string; sessionKey: string; accountId: string; + commandAuthorized: boolean; /** Chat API user_id for sending replies (may differ from webhook user_id) */ chatUserId?: string; }) => Promise; @@ -364,6 +365,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { chatType: "direct", sessionKey, accountId: account.accountId, + commandAuthorized: auth.allowed, chatUserId: replyUserId, }); diff --git a/extensions/zalo/src/secret-input.ts b/extensions/zalo/src/secret-input.ts index 702548454c3..bf218d1e48b 100644 --- a/extensions/zalo/src/secret-input.ts +++ b/extensions/zalo/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/zalo"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts new file mode 100644 index 00000000000..c366138207c --- /dev/null +++ b/src/agents/model-auth-env-vars.ts @@ -0,0 +1,42 @@ +export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], + zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], + opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + volcengine: ["VOLCANO_ENGINE_API_KEY"], + "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], + byteplus: ["BYTEPLUS_API_KEY"], + "byteplus-plan": ["BYTEPLUS_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + openai: ["OPENAI_API_KEY"], + google: ["GEMINI_API_KEY"], + voyage: ["VOYAGE_API_KEY"], + groq: ["GROQ_API_KEY"], + deepgram: ["DEEPGRAM_API_KEY"], + cerebras: ["CEREBRAS_API_KEY"], + xai: ["XAI_API_KEY"], + openrouter: ["OPENROUTER_API_KEY"], + litellm: ["LITELLM_API_KEY"], + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + moonshot: ["MOONSHOT_API_KEY"], + minimax: ["MINIMAX_API_KEY"], + nvidia: ["NVIDIA_API_KEY"], + xiaomi: ["XIAOMI_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + venice: ["VENICE_API_KEY"], + mistral: ["MISTRAL_API_KEY"], + together: ["TOGETHER_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + ollama: ["OLLAMA_API_KEY"], + vllm: ["VLLM_API_KEY"], + kilocode: ["KILOCODE_API_KEY"], +}; + +export function listKnownProviderEnvApiKeyNames(): string[] { + return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())]; +} diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts new file mode 100644 index 00000000000..e2225588df7 --- /dev/null +++ b/src/agents/model-auth-markers.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; +import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; + +describe("model auth markers", () => { + it("recognizes explicit non-secret markers", () => { + expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true); + expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); + expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); + }); + + it("recognizes known env marker names but not arbitrary all-caps keys", () => { + expect(isNonSecretApiKeyMarker("OPENAI_API_KEY")).toBe(true); + expect(isNonSecretApiKeyMarker("ALLCAPS_EXAMPLE")).toBe(false); + }); + + it("recognizes all built-in provider env marker names", () => { + for (const envVarName of listKnownProviderEnvApiKeyNames()) { + expect(isNonSecretApiKeyMarker(envVarName)).toBe(true); + } + }); + + it("can exclude env marker-name interpretation for display-only paths", () => { + expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false); + }); +}); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts new file mode 100644 index 00000000000..0b3b4960eb8 --- /dev/null +++ b/src/agents/model-auth-markers.ts @@ -0,0 +1,80 @@ +import type { SecretRefSource } from "../config/types.secrets.js"; +import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; + +export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; +export const QWEN_OAUTH_MARKER = "qwen-oauth"; +export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; +export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret +export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret + +const AWS_SDK_ENV_MARKERS = new Set([ + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_PROFILE", +]); + +// Legacy marker names kept for backward compatibility with existing models.json files. +const LEGACY_ENV_API_KEY_MARKERS = [ + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "PERPLEXITY_API_KEY", + "FIREWORKS_API_KEY", + "NOVITA_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_API_KEY", + "MINIMAX_CODE_PLAN_KEY", +]; + +const KNOWN_ENV_API_KEY_MARKERS = new Set([ + ...listKnownProviderEnvApiKeyNames(), + ...LEGACY_ENV_API_KEY_MARKERS, + ...AWS_SDK_ENV_MARKERS, +]); + +export function isAwsSdkAuthMarker(value: string): boolean { + return AWS_SDK_ENV_MARKERS.has(value.trim()); +} + +export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveNonEnvSecretRefHeaderValueMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveEnvSecretRefHeaderValueMarker(envVarName: string): string { + return `${SECRETREF_ENV_HEADER_MARKER_PREFIX}${envVarName.trim()}`; +} + +export function isSecretRefHeaderValueMarker(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed === NON_ENV_SECRETREF_MARKER || trimmed.startsWith(SECRETREF_ENV_HEADER_MARKER_PREFIX) + ); +} + +export function isNonSecretApiKeyMarker( + value: string, + opts?: { includeEnvVarName?: boolean }, +): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const isKnownMarker = + trimmed === MINIMAX_OAUTH_MARKER || + trimmed === QWEN_OAUTH_MARKER || + trimmed === OLLAMA_LOCAL_AUTH_MARKER || + trimmed === NON_ENV_SECRETREF_MARKER || + isAwsSdkAuthMarker(trimmed); + if (isKnownMarker) { + return true; + } + if (opts?.includeEnvVarName === false) { + return false; + } + // Do not treat arbitrary ALL_CAPS values as markers; only recognize the + // known env-var markers we intentionally persist for compatibility. + return KNOWN_ENV_API_KEY_MARKERS.has(trimmed); +} diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 68a117c96a9..b8b0ac9336b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -16,6 +16,8 @@ import { resolveAuthProfileOrder, resolveAuthStorePathForDisplay, } from "./auth-profiles.js"; +import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js"; import { normalizeProviderId } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -90,7 +92,7 @@ function resolveSyntheticLocalProviderAuth(params: { } return { - apiKey: "ollama-local", // pragma: allowlist secret + apiKey: OLLAMA_LOCAL_AUTH_MARKER, source: "models.providers.ollama (synthetic local key)", mode: "api-key", }; @@ -281,20 +283,14 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return { apiKey: value, source }; }; - if (normalized === "github-copilot") { - return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN"); - } - - if (normalized === "anthropic") { - return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY"); - } - - if (normalized === "chutes") { - return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY"); - } - - if (normalized === "zai") { - return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY"); + const candidates = PROVIDER_ENV_API_KEY_CANDIDATES[normalized]; + if (candidates) { + for (const envVar of candidates) { + const resolved = pick(envVar); + if (resolved) { + return resolved; + } + } } if (normalized === "google-vertex") { @@ -304,65 +300,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { } return { apiKey: envKey, source: "gcloud adc" }; } - - if (normalized === "opencode") { - return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY"); - } - - if (normalized === "qwen-portal") { - return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY"); - } - - if (normalized === "volcengine" || normalized === "volcengine-plan") { - return pick("VOLCANO_ENGINE_API_KEY"); - } - - if (normalized === "byteplus" || normalized === "byteplus-plan") { - return pick("BYTEPLUS_API_KEY"); - } - if (normalized === "minimax-portal") { - return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY"); - } - - if (normalized === "kimi-coding") { - return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY"); - } - - if (normalized === "huggingface") { - return pick("HUGGINGFACE_HUB_TOKEN") ?? pick("HF_TOKEN"); - } - - const envMap: Record = { - openai: "OPENAI_API_KEY", - google: "GEMINI_API_KEY", - voyage: "VOYAGE_API_KEY", - groq: "GROQ_API_KEY", - deepgram: "DEEPGRAM_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_API_KEY", - openrouter: "OPENROUTER_API_KEY", - litellm: "LITELLM_API_KEY", - "vercel-ai-gateway": "AI_GATEWAY_API_KEY", - "cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY", - moonshot: "MOONSHOT_API_KEY", - minimax: "MINIMAX_API_KEY", - nvidia: "NVIDIA_API_KEY", - xiaomi: "XIAOMI_API_KEY", - synthetic: "SYNTHETIC_API_KEY", - venice: "VENICE_API_KEY", - mistral: "MISTRAL_API_KEY", - opencode: "OPENCODE_API_KEY", - together: "TOGETHER_API_KEY", - qianfan: "QIANFAN_API_KEY", - ollama: "OLLAMA_API_KEY", - vllm: "VLLM_API_KEY", - kilocode: "KILOCODE_API_KEY", - }; - const envVar = envMap[normalized]; - if (!envVar) { - return null; - } - return pick(envVar); + return null; } export function resolveModelAuthMode( diff --git a/src/agents/models-config.file-mode.test.ts b/src/agents/models-config.file-mode.test.ts new file mode 100644 index 00000000000..af5719082da --- /dev/null +++ b/src/agents/models-config.file-mode.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +describe("models-config file mode", () => { + it("writes models.json with mode 0600", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); + + it("repairs models.json mode to 0600 on no-content-change paths", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + await fs.chmod(modelsPath, 0o644); + + const result = await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + expect(result.wrote).toBe(false); + + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); +}); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index bb3ca7a7cbe..ff7f06b5c7f 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { validateConfigObject } from "../config/validation.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { CUSTOM_PROXY_MODELS_CONFIG, installModelsConfigTestHooks, @@ -166,7 +167,7 @@ describe("models-config", () => { const parsed = await readGeneratedModelsJson<{ providers: Record }>; }>(); - expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-VL-01"); }); @@ -220,6 +221,117 @@ describe("models-config", () => { }); }); + it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: { source: "env", provider: "default", id: "CUSTOM_PROVIDER_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret + expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + }); + }); + + it("replaces stale merged apiKey when provider is SecretRef-managed via auth-profiles", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "minimax:default": { + type: "api_key", + provider: "minimax", + keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeAgentModelsJson({ + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: {}, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret + }); + }); + + it("replaces stale non-env marker when provider transitions back to plaintext config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: NON_ENV_SECRETREF_MARKER, + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("ALLCAPS_SAMPLE"); + }); + }); + it("uses config apiKey/baseUrl when existing agent values are empty", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts new file mode 100644 index 00000000000..0a606762d66 --- /dev/null +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + MINIMAX_OAUTH_MARKER, + NON_ENV_SECRETREF_MARKER, + QWEN_OAUTH_MARKER, +} from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("models-config provider auth provenance", () => { + it("persists env keyRef and tokenRef auth profiles as env var markers", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY", "TOGETHER_API_KEY"]); + delete process.env.VOLCANO_ENGINE_API_KEY; + delete process.env.TOGETHER_API_KEY; + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "volcengine:default": { + type: "api_key", + provider: "volcengine", + keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, + }, + "together:default": { + type: "token", + provider: "together", + tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for ref-managed profiles even when runtime plaintext is present", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "byteplus:default": { + type: "api_key", + provider: "byteplus", + key: "sk-runtime-resolved-byteplus", + keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" }, + }, + "together:default": { + type: "token", + provider: "together", + token: "tok-runtime-resolved-together", + tokenRef: { source: "exec", provider: "vault", id: "providers/together/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); + + it("keeps oauth compatibility markers for minimax-portal and qwen-portal", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "qwen-portal:default": { + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); + expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts new file mode 100644 index 00000000000..82a16dbcbee --- /dev/null +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("cloudflare-ai-gateway profile provenance", () => { + it("prefers env keyRef marker over runtime plaintext for persistence", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]); + delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for non-env keyRef cloudflare profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "file", provider: "vault", id: "/cloudflare/apiKey" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts new file mode 100644 index 00000000000..6e8ebfbc0ac --- /dev/null +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("provider discovery auth marker guardrails", () => { + let originalVitest: string | undefined; + let originalNodeEnv: string | undefined; + let originalFetch: typeof globalThis.fetch | undefined; + + afterEach(() => { + if (originalVitest !== undefined) { + process.env.VITEST = originalVitest; + } else { + delete process.env.VITEST; + } + if (originalNodeEnv !== undefined) { + process.env.NODE_ENV = originalNodeEnv; + } else { + delete process.env.NODE_ENV; + } + if (originalFetch) { + globalThis.fetch = originalFetch; + } + }); + + function enableDiscovery() { + originalVitest = process.env.VITEST; + originalNodeEnv = process.env.NODE_ENV; + originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + } + + it("does not send marker value as vLLM bearer token during discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + keyRef: { source: "file", provider: "vault", id: "/vllm/apiKey" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const request = fetchMock.mock.calls[0]?.[1] as + | { headers?: Record } + | undefined; + expect(request?.headers?.Authorization).toBeUndefined(); + }); + + it("does not call Hugging Face discovery with marker-backed credentials", async () => { + enableDiscovery(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "huggingface:default": { + type: "api_key", + provider: "huggingface", + keyRef: { source: "exec", provider: "vault", id: "providers/hf/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("router.huggingface.co"), + ); + expect(huggingfaceCalls).toHaveLength(0); + }); + + it("keeps all-caps plaintext API keys for authenticated discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "vllm/test-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + key: "ALLCAPS_SAMPLE", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await resolveImplicitProviders({ agentDir }); + const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000")); + const request = vllmCall?.[1] as { headers?: Record } | undefined; + expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE"); + }); +}); diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index cccd54851d8..1271b30faed 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { normalizeProviders } from "./models-config.providers.js"; describe("normalizeProviders", () => { @@ -73,4 +74,30 @@ describe("normalizeProviders", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("normalizes SecretRef-backed provider headers to non-secret marker values", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + try { + const providers: NonNullable["providers"]> = { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { source: "env", provider: "default", id: "OPENAI_HEADER_TOKEN" }, + "X-Tenant-Token": { source: "file", provider: "vault", id: "/openai/token" }, + }, + models: [], + }, + }; + + const normalized = normalizeProviders({ + providers, + agentDir, + }); + expect(normalized?.openai?.headers?.Authorization).toBe("secretref-env:OPENAI_HEADER_TOKEN"); + expect(normalized?.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 1c7ad06699c..62bdf70f04e 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { coerceSecretRef } from "../config/types.secrets.js"; +import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_COPILOT_API_BASE_URL, @@ -41,6 +41,15 @@ import { buildHuggingfaceModelDefinition, } from "./huggingface-models.js"; import { discoverKilocodeModels } from "./kilocode-models.js"; +import { + MINIMAX_OAUTH_MARKER, + OLLAMA_LOCAL_AUTH_MARKER, + QWEN_OAUTH_MARKER, + isNonSecretApiKeyMarker, + resolveNonEnvSecretRefApiKeyMarker, + resolveNonEnvSecretRefHeaderValueMarker, + resolveEnvSecretRefHeaderValueMarker, +} from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; import { @@ -63,7 +72,6 @@ const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -const MINIMAX_OAUTH_PLACEHOLDER = "minimax-oauth"; // Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price const MINIMAX_API_COST = { input: 0.3, @@ -133,7 +141,6 @@ const KIMI_CODING_DEFAULT_COST = { }; const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; -const QWEN_PORTAL_OAUTH_PLACEHOLDER = "qwen-oauth"; const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; const QWEN_PORTAL_DEFAULT_COST = { @@ -404,35 +411,125 @@ function resolveAwsSdkApiKeyVarName(): string { return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE"; } +function normalizeHeaderValues(params: { + headers: ProviderConfig["headers"] | undefined; + secretDefaults: + | { + env?: string; + file?: string; + exec?: string; + } + | undefined; +}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } { + const { headers } = params; + if (!headers) { + return { headers, mutated: false }; + } + let mutated = false; + const nextHeaders: Record[string]> = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + const resolvedRef = resolveSecretInputRef({ + value: headerValue, + defaults: params.secretDefaults, + }).ref; + if (!resolvedRef || !resolvedRef.id.trim()) { + nextHeaders[headerName] = headerValue; + continue; + } + mutated = true; + nextHeaders[headerName] = + resolvedRef.source === "env" + ? resolveEnvSecretRefHeaderValueMarker(resolvedRef.id) + : resolveNonEnvSecretRefHeaderValueMarker(resolvedRef.source); + } + if (!mutated) { + return { headers, mutated: false }; + } + return { headers: nextHeaders, mutated: true }; +} + +type ProfileApiKeyResolution = { + apiKey: string; + source: "plaintext" | "env-ref" | "non-env-ref"; + /** Optional secret value that may be used for provider discovery only. */ + discoveryApiKey?: string; +}; + +function toDiscoveryApiKey(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || isNonSecretApiKeyMarker(trimmed)) { + return undefined; + } + return trimmed; +} + +function resolveApiKeyFromCredential( + cred: ReturnType["profiles"][string] | undefined, +): ProfileApiKeyResolution | undefined { + if (!cred) { + return undefined; + } + if (cred.type === "api_key") { + const keyRef = coerceSecretRef(cred.keyRef); + if (keyRef && keyRef.id.trim()) { + if (keyRef.source === "env") { + const envVar = keyRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(keyRef.source), + source: "non-env-ref", + }; + } + if (cred.key?.trim()) { + return { + apiKey: cred.key, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.key), + }; + } + return undefined; + } + if (cred.type === "token") { + const tokenRef = coerceSecretRef(cred.tokenRef); + if (tokenRef && tokenRef.id.trim()) { + if (tokenRef.source === "env") { + const envVar = tokenRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(tokenRef.source), + source: "non-env-ref", + }; + } + if (cred.token?.trim()) { + return { + apiKey: cred.token, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.token), + }; + } + } + return undefined; +} + function resolveApiKeyFromProfiles(params: { provider: string; store: ReturnType; -}): string | undefined { +}): ProfileApiKeyResolution | undefined { const ids = listProfilesForProvider(params.store, params.provider); for (const id of ids) { - const cred = params.store.profiles[id]; - if (!cred) { - continue; - } - if (cred.type === "api_key") { - if (cred.key?.trim()) { - return cred.key; - } - const keyRef = coerceSecretRef(cred.keyRef); - if (keyRef?.source === "env" && keyRef.id.trim()) { - return keyRef.id.trim(); - } - continue; - } - if (cred.type === "token") { - if (cred.token?.trim()) { - return cred.token; - } - const tokenRef = coerceSecretRef(cred.tokenRef); - if (tokenRef?.source === "env" && tokenRef.id.trim()) { - return tokenRef.id.trim(); - } - continue; + const resolved = resolveApiKeyFromCredential(params.store.profiles[id]); + if (resolved) { + return resolved; } } return undefined; @@ -484,6 +581,12 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig export function normalizeProviders(params: { providers: ModelsConfig["providers"]; agentDir: string; + secretDefaults?: { + env?: string; + file?: string; + exec?: string; + }; + secretRefManagedProviders?: Set; }): ModelsConfig["providers"] { const { providers } = params; if (!providers) { @@ -505,18 +608,51 @@ export function normalizeProviders(params: { mutated = true; } let normalizedProvider = provider; - const configuredApiKey = normalizedProvider.apiKey; - - // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". - if ( - typeof configuredApiKey === "string" && - normalizeApiKeyConfig(configuredApiKey) !== configuredApiKey - ) { + const normalizedHeaders = normalizeHeaderValues({ + headers: normalizedProvider.headers, + secretDefaults: params.secretDefaults, + }); + if (normalizedHeaders.mutated) { mutated = true; - normalizedProvider = { - ...normalizedProvider, - apiKey: normalizeApiKeyConfig(configuredApiKey), - }; + normalizedProvider = { ...normalizedProvider, headers: normalizedHeaders.headers }; + } + const configuredApiKey = normalizedProvider.apiKey; + const configuredApiKeyRef = resolveSecretInputRef({ + value: configuredApiKey, + defaults: params.secretDefaults, + }).ref; + const profileApiKey = resolveApiKeyFromProfiles({ + provider: normalizedKey, + store: authStore, + }); + + if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { + const marker = + configuredApiKeyRef.source === "env" + ? configuredApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source); + if (normalizedProvider.apiKey !== marker) { + mutated = true; + normalizedProvider = { ...normalizedProvider, apiKey: marker }; + } + params.secretRefManagedProviders?.add(normalizedKey); + } else if (typeof configuredApiKey === "string") { + // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". + const normalizedConfiguredApiKey = normalizeApiKeyConfig(configuredApiKey); + if (normalizedConfiguredApiKey !== configuredApiKey) { + mutated = true; + normalizedProvider = { + ...normalizedProvider, + apiKey: normalizedConfiguredApiKey, + }; + } + if ( + profileApiKey && + profileApiKey.source !== "plaintext" && + normalizedConfiguredApiKey === profileApiKey.apiKey + ) { + params.secretRefManagedProviders?.add(normalizedKey); + } } // If a provider defines models, pi's ModelRegistry requires apiKey to be set. @@ -534,12 +670,11 @@ export function normalizeProviders(params: { normalizedProvider = { ...normalizedProvider, apiKey }; } else { const fromEnv = resolveEnvApiKeyVarName(normalizedKey); - const fromProfiles = resolveApiKeyFromProfiles({ - provider: normalizedKey, - store: authStore, - }); - const apiKey = fromEnv ?? fromProfiles; + const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { + if (profileApiKey && profileApiKey.source !== "plaintext") { + params.secretRefManagedProviders?.add(normalizedKey); + } mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } @@ -778,14 +913,8 @@ async function buildOllamaProvider( }; } -async function buildHuggingfaceProvider(apiKey?: string): Promise { - // Resolve env var name to value for discovery (GET /v1/models requires Bearer token). - const resolvedSecret = - apiKey?.trim() !== "" - ? /^[A-Z][A-Z0-9_]*$/.test(apiKey!.trim()) - ? (process.env[apiKey!.trim()] ?? "").trim() - : apiKey!.trim() - : ""; +async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { + const resolvedSecret = toDiscoveryApiKey(discoveryApiKey) ?? ""; const models = resolvedSecret !== "" ? await discoverHuggingfaceModels(resolvedSecret) @@ -946,10 +1075,24 @@ export async function resolveImplicitProviders(params: { const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); + const resolveProviderApiKey = ( + provider: string, + ): { apiKey: string | undefined; discoveryApiKey?: string } => { + const envVar = resolveEnvApiKeyVarName(provider); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore }); + return { + apiKey: fromProfiles?.apiKey, + discoveryApiKey: fromProfiles?.discoveryApiKey, + }; + }; - const minimaxKey = - resolveEnvApiKeyVarName("minimax") ?? - resolveApiKeyFromProfiles({ provider: "minimax", store: authStore }); + const minimaxKey = resolveProviderApiKey("minimax").apiKey; if (minimaxKey) { providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; } @@ -958,34 +1101,26 @@ export async function resolveImplicitProviders(params: { if (minimaxOauthProfile.length > 0) { providers["minimax-portal"] = { ...buildMinimaxPortalProvider(), - apiKey: MINIMAX_OAUTH_PLACEHOLDER, + apiKey: MINIMAX_OAUTH_MARKER, }; } - const moonshotKey = - resolveEnvApiKeyVarName("moonshot") ?? - resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); + const moonshotKey = resolveProviderApiKey("moonshot").apiKey; if (moonshotKey) { providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; } - const kimiCodingKey = - resolveEnvApiKeyVarName("kimi-coding") ?? - resolveApiKeyFromProfiles({ provider: "kimi-coding", store: authStore }); + const kimiCodingKey = resolveProviderApiKey("kimi-coding").apiKey; if (kimiCodingKey) { providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey }; } - const syntheticKey = - resolveEnvApiKeyVarName("synthetic") ?? - resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore }); + const syntheticKey = resolveProviderApiKey("synthetic").apiKey; if (syntheticKey) { providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey }; } - const veniceKey = - resolveEnvApiKeyVarName("venice") ?? - resolveApiKeyFromProfiles({ provider: "venice", store: authStore }); + const veniceKey = resolveProviderApiKey("venice").apiKey; if (veniceKey) { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } @@ -994,13 +1129,11 @@ export async function resolveImplicitProviders(params: { if (qwenProfiles.length > 0) { providers["qwen-portal"] = { ...buildQwenPortalProvider(), - apiKey: QWEN_PORTAL_OAUTH_PLACEHOLDER, + apiKey: QWEN_OAUTH_MARKER, }; } - const volcengineKey = - resolveEnvApiKeyVarName("volcengine") ?? - resolveApiKeyFromProfiles({ provider: "volcengine", store: authStore }); + const volcengineKey = resolveProviderApiKey("volcengine").apiKey; if (volcengineKey) { providers.volcengine = { ...buildDoubaoProvider(), apiKey: volcengineKey }; providers["volcengine-plan"] = { @@ -1009,9 +1142,7 @@ export async function resolveImplicitProviders(params: { }; } - const byteplusKey = - resolveEnvApiKeyVarName("byteplus") ?? - resolveApiKeyFromProfiles({ provider: "byteplus", store: authStore }); + const byteplusKey = resolveProviderApiKey("byteplus").apiKey; if (byteplusKey) { providers.byteplus = { ...buildBytePlusProvider(), apiKey: byteplusKey }; providers["byteplus-plan"] = { @@ -1020,9 +1151,7 @@ export async function resolveImplicitProviders(params: { }; } - const xiaomiKey = - resolveEnvApiKeyVarName("xiaomi") ?? - resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore }); + const xiaomiKey = resolveProviderApiKey("xiaomi").apiKey; if (xiaomiKey) { providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey }; } @@ -1042,7 +1171,9 @@ export async function resolveImplicitProviders(params: { if (!baseUrl) { continue; } - const apiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway") ?? cred.key?.trim() ?? ""; + const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway"); + const profileApiKey = resolveApiKeyFromCredential(cred)?.apiKey; + const apiKey = envVarApiKey ?? profileApiKey ?? ""; if (!apiKey) { continue; } @@ -1059,9 +1190,7 @@ export async function resolveImplicitProviders(params: { // Use the user's configured baseUrl (from explicit providers) for model // discovery so that remote / non-default Ollama instances are reachable. // Skip discovery when explicit models are already defined. - const ollamaKey = - resolveEnvApiKeyVarName("ollama") ?? - resolveApiKeyFromProfiles({ provider: "ollama", store: authStore }); + const ollamaKey = resolveProviderApiKey("ollama").apiKey; const explicitOllama = params.explicitProviders?.ollama; const hasExplicitModels = Array.isArray(explicitOllama?.models) && explicitOllama.models.length > 0; @@ -1070,7 +1199,7 @@ export async function resolveImplicitProviders(params: { ...explicitOllama, baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl), api: explicitOllama.api ?? "ollama", - apiKey: ollamaKey ?? explicitOllama.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } else { const ollamaBaseUrl = explicitOllama?.baseUrl; @@ -1083,7 +1212,7 @@ export async function resolveImplicitProviders(params: { if (ollamaProvider.models.length > 0 || ollamaKey || explicitOllama?.apiKey) { providers.ollama = { ...ollamaProvider, - apiKey: ollamaKey ?? explicitOllama?.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } } @@ -1091,23 +1220,16 @@ export async function resolveImplicitProviders(params: { // vLLM provider - OpenAI-compatible local server (opt-in via env/profile). // If explicitly configured, keep user-defined models/settings as-is. if (!params.explicitProviders?.vllm) { - const vllmEnvVar = resolveEnvApiKeyVarName("vllm"); - const vllmProfileKey = resolveApiKeyFromProfiles({ provider: "vllm", store: authStore }); - const vllmKey = vllmEnvVar ?? vllmProfileKey; + const { apiKey: vllmKey, discoveryApiKey } = resolveProviderApiKey("vllm"); if (vllmKey) { - const discoveryApiKey = vllmEnvVar - ? (process.env[vllmEnvVar]?.trim() ?? "") - : (vllmProfileKey ?? ""); providers.vllm = { - ...(await buildVllmProvider({ apiKey: discoveryApiKey || undefined })), + ...(await buildVllmProvider({ apiKey: discoveryApiKey })), apiKey: vllmKey, }; } } - const togetherKey = - resolveEnvApiKeyVarName("together") ?? - resolveApiKeyFromProfiles({ provider: "together", store: authStore }); + const togetherKey = resolveProviderApiKey("together").apiKey; if (togetherKey) { providers.together = { ...buildTogetherProvider(), @@ -1115,41 +1237,32 @@ export async function resolveImplicitProviders(params: { }; } - const huggingfaceKey = - resolveEnvApiKeyVarName("huggingface") ?? - resolveApiKeyFromProfiles({ provider: "huggingface", store: authStore }); + const { apiKey: huggingfaceKey, discoveryApiKey: huggingfaceDiscoveryApiKey } = + resolveProviderApiKey("huggingface"); if (huggingfaceKey) { - const hfProvider = await buildHuggingfaceProvider(huggingfaceKey); + const hfProvider = await buildHuggingfaceProvider(huggingfaceDiscoveryApiKey); providers.huggingface = { ...hfProvider, apiKey: huggingfaceKey, }; } - const qianfanKey = - resolveEnvApiKeyVarName("qianfan") ?? - resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore }); + const qianfanKey = resolveProviderApiKey("qianfan").apiKey; if (qianfanKey) { providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey }; } - const openrouterKey = - resolveEnvApiKeyVarName("openrouter") ?? - resolveApiKeyFromProfiles({ provider: "openrouter", store: authStore }); + const openrouterKey = resolveProviderApiKey("openrouter").apiKey; if (openrouterKey) { providers.openrouter = { ...buildOpenrouterProvider(), apiKey: openrouterKey }; } - const nvidiaKey = - resolveEnvApiKeyVarName("nvidia") ?? - resolveApiKeyFromProfiles({ provider: "nvidia", store: authStore }); + const nvidiaKey = resolveProviderApiKey("nvidia").apiKey; if (nvidiaKey) { providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey }; } - const kilocodeKey = - resolveEnvApiKeyVarName("kilocode") ?? - resolveApiKeyFromProfiles({ provider: "kilocode", store: authStore }); + const kilocodeKey = resolveProviderApiKey("kilocode").apiKey; if (kilocodeKey) { providers.kilocode = { ...(await buildKilocodeProviderWithDiscovery()), apiKey: kilocodeKey }; } diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts new file mode 100644 index 00000000000..6d6ea0284ee --- /dev/null +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + loadConfig, + setRuntimeConfigSnapshot, +} from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config runtime source snapshot", () => { + it("uses runtime source snapshot markers when passed the active runtime config", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses non-env marker from runtime source snapshot for file refs", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" }, + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-runtime-moonshot", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret + }, + "X-Tenant-Token": { + source: "file", + provider: "vault", + id: "/providers/openai/tenantToken", + }, + }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: "Bearer runtime-openai-token", + "X-Tenant-Token": "runtime-tenant-token", + }, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record }>; + }>(); + expect(parsed.providers.openai?.headers?.Authorization).toBe( + "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret + ); + expect(parsed.providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); +}); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index e31d61044c3..11832b30b15 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,9 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { type OpenClawConfig, loadConfig } from "../config/config.js"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + type OpenClawConfig, + loadConfig, +} from "../config/config.js"; import { applyConfigEnvVars } from "../config/env-vars.js"; import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { normalizeProviders, type ProviderConfig, @@ -15,6 +21,7 @@ import { type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; +const MODELS_JSON_WRITE_LOCKS = new Map>(); function resolvePreferredTokenLimit(explicitValue: number, implicitValue: number): number { // Keep catalog refresh behavior for stale low values while preserving @@ -141,8 +148,9 @@ async function resolveProvidersForModelsJson(params: { function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record[string]>; + secretRefManagedProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders } = params; + const { nextProviders, existingProviders, secretRefManagedProviders } = params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -159,7 +167,12 @@ function mergeWithExistingProviderSecrets(params: { continue; } const preserved: Record = {}; - if (typeof existing.apiKey === "string" && existing.apiKey) { + if ( + !secretRefManagedProviders.has(key) && + typeof existing.apiKey === "string" && + existing.apiKey && + !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) + ) { preserved.apiKey = existing.apiKey; } if (typeof existing.baseUrl === "string" && existing.baseUrl) { @@ -174,6 +187,7 @@ async function resolveProvidersForMode(params: { mode: NonNullable; targetPath: string; providers: Record; + secretRefManagedProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -189,6 +203,7 @@ async function resolveProvidersForMode(params: { return mergeWithExistingProviderSecrets({ nextProviders: params.providers, existingProviders, + secretRefManagedProviders: params.secretRefManagedProviders, }); } @@ -200,45 +215,94 @@ async function readRawFile(pathname: string): Promise { } } +async function ensureModelsFileMode(pathname: string): Promise { + await fs.chmod(pathname, 0o600).catch(() => { + // best-effort + }); +} + +function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig { + const runtimeSource = getRuntimeConfigSourceSnapshot(); + if (!runtimeSource) { + return config ?? loadConfig(); + } + if (!config) { + return runtimeSource; + } + const runtimeResolved = getRuntimeConfigSnapshot(); + if (runtimeResolved && config === runtimeResolved) { + return runtimeSource; + } + return config; +} + +async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise { + const prior = MODELS_JSON_WRITE_LOCKS.get(targetPath) ?? Promise.resolve(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const pending = prior.then(() => gate); + MODELS_JSON_WRITE_LOCKS.set(targetPath, pending); + try { + await prior; + return await run(); + } finally { + release(); + if (MODELS_JSON_WRITE_LOCKS.get(targetPath) === pending) { + MODELS_JSON_WRITE_LOCKS.delete(targetPath); + } + } +} + export async function ensureOpenClawModelsJson( config?: OpenClawConfig, agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { - const cfg = config ?? loadConfig(); + const cfg = resolveModelsConfigInput(config); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); - - // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are - // available in process.env before implicit provider discovery. Some - // callers (agent runner, tools) pass config objects that haven't gone - // through the full loadConfig() pipeline which applies these. - applyConfigEnvVars(cfg); - - const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); - - if (Object.keys(providers).length === 0) { - return { agentDir, wrote: false }; - } - - const mode = cfg.models?.mode ?? DEFAULT_MODE; const targetPath = path.join(agentDir, "models.json"); - const mergedProviders = await resolveProvidersForMode({ - mode, - targetPath, - providers, + + return await withModelsJsonWriteLock(targetPath, async () => { + // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are + // available in process.env before implicit provider discovery. Some + // callers (agent runner, tools) pass config objects that haven't gone + // through the full loadConfig() pipeline which applies these. + applyConfigEnvVars(cfg); + + const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); + + if (Object.keys(providers).length === 0) { + return { agentDir, wrote: false }; + } + + const mode = cfg.models?.mode ?? DEFAULT_MODE; + const secretRefManagedProviders = new Set(); + + const normalizedProviders = + normalizeProviders({ + providers, + agentDir, + secretDefaults: cfg.secrets?.defaults, + secretRefManagedProviders, + }) ?? providers; + const mergedProviders = await resolveProvidersForMode({ + mode, + targetPath, + providers: normalizedProviders, + secretRefManagedProviders, + }); + const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; + const existingRaw = await readRawFile(targetPath); + + if (existingRaw === next) { + await ensureModelsFileMode(targetPath); + return { agentDir, wrote: false }; + } + + await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(targetPath, next, { mode: 0o600 }); + await ensureModelsFileMode(targetPath); + return { agentDir, wrote: true }; }); - - const normalizedProviders = normalizeProviders({ - providers: mergedProviders, - agentDir, - }); - const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; - const existingRaw = await readRawFile(targetPath); - - if (existingRaw === next) { - return { agentDir, wrote: false }; - } - - await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await fs.writeFile(targetPath, next, { mode: 0o600 }); - return { agentDir, wrote: true }; } diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts new file mode 100644 index 00000000000..a69fd43b830 --- /dev/null +++ b/src/agents/models-config.write-serialization.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config write serialization", () => { + it("serializes concurrent models.json writes to avoid overlap", async () => { + await withModelsTempHome(async () => { + const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const firstModel = first.models?.providers?.["custom-proxy"]?.models?.[0]; + const secondModel = second.models?.providers?.["custom-proxy"]?.models?.[0]; + if (!firstModel || !secondModel) { + throw new Error("custom-proxy fixture missing expected model entries"); + } + firstModel.name = "Proxy A"; + secondModel.name = "Proxy B with longer name"; + + const originalWriteFile = fs.writeFile.bind(fs); + let inFlightWrites = 0; + let maxInFlightWrites = 0; + const writeSpy = vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + inFlightWrites += 1; + if (inFlightWrites > maxInFlightWrites) { + maxInFlightWrites = inFlightWrites; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + try { + return await originalWriteFile(...args); + } finally { + inFlightWrites -= 1; + } + }); + + try { + await Promise.all([ensureOpenClawModelsJson(first), ensureOpenClawModelsJson(second)]); + } finally { + writeSpy.mockRestore(); + } + + expect(maxInFlightWrites).toBe(1); + const parsed = await readGeneratedModelsJson<{ + providers: { "custom-proxy"?: { models?: Array<{ name?: string }> } }; + }>(); + expect(parsed.providers["custom-proxy"]?.models?.[0]?.name).toBe("Proxy B with longer name"); + }); + }); +}); diff --git a/src/agents/pi-embedded-block-chunker.test.ts b/src/agents/pi-embedded-block-chunker.test.ts index 0b6c858ef95..c8b1f5dda55 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/pi-embedded-block-chunker.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as fences from "../markdown/fences.js"; import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) { @@ -120,4 +121,20 @@ describe("EmbeddedBlockChunker", () => { expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]); expect(chunker.bufferedText).toBe("After fence"); }); + + it("parses fence spans once per drain call for long fenced buffers", () => { + const parseSpy = vi.spyOn(fences, "parseFenceSpans"); + const chunker = new EmbeddedBlockChunker({ + minChars: 20, + maxChars: 80, + breakPreference: "paragraph", + }); + + chunker.append(`\`\`\`txt\n${"line\n".repeat(600)}\`\`\``); + const chunks = drainChunks(chunker); + + expect(chunks.length).toBeGreaterThan(2); + expect(parseSpy).toHaveBeenCalledTimes(1); + parseSpy.mockRestore(); + }); }); diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts index b1266a1557a..11eddc2d190 100644 --- a/src/agents/pi-embedded-block-chunker.ts +++ b/src/agents/pi-embedded-block-chunker.ts @@ -12,6 +12,7 @@ export type BlockReplyChunking = { type FenceSplit = { closeFenceLine: string; reopenFenceLine: string; + fence: FenceSpan; }; type BreakResult = { @@ -28,6 +29,7 @@ function findSafeSentenceBreakIndex( text: string, fenceSpans: FenceSpan[], minChars: number, + offset = 0, ): number { const matches = text.matchAll(/[.!?](?=\s|$)/g); let sentenceIdx = -1; @@ -37,7 +39,7 @@ function findSafeSentenceBreakIndex( continue; } const candidate = at + 1; - if (isSafeFenceBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, offset + candidate)) { sentenceIdx = candidate; } } @@ -49,8 +51,9 @@ function findSafeParagraphBreakIndex(params: { fenceSpans: FenceSpan[]; minChars: number; reverse: boolean; + offset?: number; }): number { - const { text, fenceSpans, minChars, reverse } = params; + const { text, fenceSpans, minChars, reverse, offset = 0 } = params; let paragraphIdx = reverse ? text.lastIndexOf("\n\n") : text.indexOf("\n\n"); while (reverse ? paragraphIdx >= minChars : paragraphIdx !== -1) { const candidates = [paragraphIdx, paragraphIdx + 1]; @@ -61,7 +64,7 @@ function findSafeParagraphBreakIndex(params: { if (candidate < 0 || candidate >= text.length) { continue; } - if (isSafeFenceBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, offset + candidate)) { return candidate; } } @@ -77,11 +80,12 @@ function findSafeNewlineBreakIndex(params: { fenceSpans: FenceSpan[]; minChars: number; reverse: boolean; + offset?: number; }): number { - const { text, fenceSpans, minChars, reverse } = params; + const { text, fenceSpans, minChars, reverse, offset = 0 } = params; let newlineIdx = reverse ? text.lastIndexOf("\n") : text.indexOf("\n"); while (reverse ? newlineIdx >= minChars : newlineIdx !== -1) { - if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, newlineIdx)) { + if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, offset + newlineIdx)) { return newlineIdx; } newlineIdx = reverse @@ -125,14 +129,7 @@ export class EmbeddedBlockChunker { const minChars = Math.max(1, Math.floor(this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); - // When flushOnParagraph is set (chunkMode="newline"), eagerly split on \n\n - // boundaries regardless of minChars so each paragraph is sent immediately. - if (this.#chunking.flushOnParagraph && !force) { - this.#drainParagraphs(emit, maxChars); - return; - } - - if (this.#buffer.length < minChars && !force) { + if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) { return; } @@ -144,108 +141,132 @@ export class EmbeddedBlockChunker { return; } - while (this.#buffer.length >= minChars || (force && this.#buffer.length > 0)) { + const source = this.#buffer; + const fenceSpans = parseFenceSpans(source); + let start = 0; + let reopenFence: FenceSpan | undefined; + + while (start < source.length) { + const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; + const remainingLength = reopenPrefix.length + (source.length - start); + + if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) { + break; + } + + if (this.#chunking.flushOnParagraph && !force) { + const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start); + const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length); + if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) { + const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`; + if (chunk.trim().length > 0) { + emit(chunk); + } + start = skipLeadingNewlines(source, paragraphBreak.index + paragraphBreak.length); + reopenFence = undefined; + continue; + } + if (remainingLength < maxChars) { + break; + } + } + + const view = source.slice(start); const breakResult = - force && this.#buffer.length <= maxChars - ? this.#pickSoftBreakIndex(this.#buffer, 1) - : this.#pickBreakIndex(this.#buffer, force ? 1 : undefined); + force && remainingLength <= maxChars + ? this.#pickSoftBreakIndex(view, fenceSpans, 1, start) + : this.#pickBreakIndex( + view, + fenceSpans, + force || this.#chunking.flushOnParagraph ? 1 : undefined, + start, + ); if (breakResult.index <= 0) { if (force) { - emit(this.#buffer); - this.#buffer = ""; + emit(`${reopenPrefix}${source.slice(start)}`); + start = source.length; + reopenFence = undefined; } - return; + break; } - if (!this.#emitBreakResult(breakResult, emit)) { + const consumed = this.#emitBreakResult({ + breakResult, + emit, + reopenPrefix, + source, + start, + }); + if (consumed === null) { continue; } + start = consumed.start; + reopenFence = consumed.reopenFence; - if (this.#buffer.length < minChars && !force) { - return; + const nextLength = + (reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start); + if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) { + break; } - if (this.#buffer.length < maxChars && !force) { - return; + if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) { + break; } } + this.#buffer = reopenFence + ? `${reopenFence.openLine}\n${source.slice(start)}` + : stripLeadingNewlines(source.slice(start)); } - /** Eagerly emit complete paragraphs (text before \n\n) regardless of minChars. */ - #drainParagraphs(emit: (chunk: string) => void, maxChars: number) { - while (this.#buffer.length > 0) { - const fenceSpans = parseFenceSpans(this.#buffer); - const paragraphBreak = findNextParagraphBreak(this.#buffer, fenceSpans); - if (!paragraphBreak || paragraphBreak.index > maxChars) { - // No paragraph boundary yet (or the next boundary is too far). If the - // buffer exceeds maxChars, fall back to normal break logic to avoid - // oversized chunks or unbounded accumulation. - if (this.#buffer.length >= maxChars) { - const breakResult = this.#pickBreakIndex(this.#buffer, 1); - if (breakResult.index > 0) { - this.#emitBreakResult(breakResult, emit); - continue; - } - } - return; - } - - const chunk = this.#buffer.slice(0, paragraphBreak.index); - if (chunk.trim().length > 0) { - emit(chunk); - } - this.#buffer = stripLeadingNewlines( - this.#buffer.slice(paragraphBreak.index + paragraphBreak.length), - ); - } - } - - #emitBreakResult(breakResult: BreakResult, emit: (chunk: string) => void): boolean { + #emitBreakResult(params: { + breakResult: BreakResult; + emit: (chunk: string) => void; + reopenPrefix: string; + source: string; + start: number; + }): { start: number; reopenFence?: FenceSpan } | null { + const { breakResult, emit, reopenPrefix, source, start } = params; const breakIdx = breakResult.index; if (breakIdx <= 0) { - return false; + return null; } - let rawChunk = this.#buffer.slice(0, breakIdx); + const absoluteBreakIdx = start + breakIdx; + let rawChunk = `${reopenPrefix}${source.slice(start, absoluteBreakIdx)}`; if (rawChunk.trim().length === 0) { - this.#buffer = stripLeadingNewlines(this.#buffer.slice(breakIdx)).trimStart(); - return false; + return { start: skipLeadingNewlines(source, absoluteBreakIdx), reopenFence: undefined }; } - let nextBuffer = this.#buffer.slice(breakIdx); const fenceSplit = breakResult.fenceSplit; if (fenceSplit) { const closeFence = rawChunk.endsWith("\n") ? `${fenceSplit.closeFenceLine}\n` : `\n${fenceSplit.closeFenceLine}\n`; rawChunk = `${rawChunk}${closeFence}`; - - const reopenFence = fenceSplit.reopenFenceLine.endsWith("\n") - ? fenceSplit.reopenFenceLine - : `${fenceSplit.reopenFenceLine}\n`; - nextBuffer = `${reopenFence}${nextBuffer}`; } emit(rawChunk); if (fenceSplit) { - this.#buffer = nextBuffer; - } else { - const nextStart = - breakIdx < this.#buffer.length && /\s/.test(this.#buffer[breakIdx]) - ? breakIdx + 1 - : breakIdx; - this.#buffer = stripLeadingNewlines(this.#buffer.slice(nextStart)); + return { start: absoluteBreakIdx, reopenFence: fenceSplit.fence }; } - return true; + const nextStart = + absoluteBreakIdx < source.length && /\s/.test(source[absoluteBreakIdx]) + ? absoluteBreakIdx + 1 + : absoluteBreakIdx; + return { start: skipLeadingNewlines(source, nextStart), reopenFence: undefined }; } - #pickSoftBreakIndex(buffer: string, minCharsOverride?: number): BreakResult { + #pickSoftBreakIndex( + buffer: string, + fenceSpans: FenceSpan[], + minCharsOverride?: number, + offset = 0, + ): BreakResult { const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars)); if (buffer.length < minChars) { return { index: -1 }; } - const fenceSpans = parseFenceSpans(buffer); const preference = this.#chunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { @@ -254,6 +275,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: false, + offset, }); if (paragraphIdx !== -1) { return { index: paragraphIdx }; @@ -266,6 +288,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: false, + offset, }); if (newlineIdx !== -1) { return { index: newlineIdx }; @@ -273,7 +296,7 @@ export class EmbeddedBlockChunker { } if (preference !== "newline") { - const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars); + const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars, offset); if (sentenceIdx !== -1) { return { index: sentenceIdx }; } @@ -282,14 +305,18 @@ export class EmbeddedBlockChunker { return { index: -1 }; } - #pickBreakIndex(buffer: string, minCharsOverride?: number): BreakResult { + #pickBreakIndex( + buffer: string, + fenceSpans: FenceSpan[], + minCharsOverride?: number, + offset = 0, + ): BreakResult { const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); if (buffer.length < minChars) { return { index: -1 }; } const window = buffer.slice(0, Math.min(maxChars, buffer.length)); - const fenceSpans = parseFenceSpans(buffer); const preference = this.#chunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { @@ -298,6 +325,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: true, + offset, }); if (paragraphIdx !== -1) { return { index: paragraphIdx }; @@ -310,6 +338,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: true, + offset, }); if (newlineIdx !== -1) { return { index: newlineIdx }; @@ -317,7 +346,7 @@ export class EmbeddedBlockChunker { } if (preference !== "newline") { - const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars); + const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars, offset); if (sentenceIdx !== -1) { return { index: sentenceIdx }; } @@ -328,22 +357,23 @@ export class EmbeddedBlockChunker { } for (let i = window.length - 1; i >= minChars; i--) { - if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, i)) { + if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, offset + i)) { return { index: i }; } } if (buffer.length >= maxChars) { - if (isSafeFenceBreak(fenceSpans, maxChars)) { + if (isSafeFenceBreak(fenceSpans, offset + maxChars)) { return { index: maxChars }; } - const fence = findFenceSpanAt(fenceSpans, maxChars); + const fence = findFenceSpanAt(fenceSpans, offset + maxChars); if (fence) { return { index: maxChars, fenceSplit: { closeFenceLine: `${fence.indent}${fence.marker}`, reopenFenceLine: fence.openLine, + fence, }, }; } @@ -354,12 +384,17 @@ export class EmbeddedBlockChunker { } } -function stripLeadingNewlines(value: string): string { - let i = 0; +function skipLeadingNewlines(value: string, start = 0): number { + let i = start; while (i < value.length && value[i] === "\n") { i++; } - return i > 0 ? value.slice(i) : value; + return i; +} + +function stripLeadingNewlines(value: string): string { + const start = skipLeadingNewlines(value); + return start > 0 ? value.slice(start) : value; } function findNextParagraphBreak( diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index d23b68d32b6..ca12a76cb36 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -179,6 +179,28 @@ describe("buildInlineProviderModels", () => { expect(result).toHaveLength(1); expect(result[0].headers).toBeUndefined(); }); + + it("preserves literal marker-shaped headers in inline provider models", () => { + const providers: Parameters[0] = { + custom: { + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + models: [makeModel("custom-model")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].headers).toEqual({ + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }); + }); }); describe("resolveModel", () => { @@ -223,6 +245,56 @@ describe("resolveModel", () => { }); }); + it("preserves literal marker-shaped provider headers in fallback models", () => { + const cfg = { + models: { + providers: { + custom: { + baseUrl: "http://localhost:9000", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }, + models: [makeModel("listed-model")], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }); + }); + + it("drops marker headers from discovered models.json entries", () => { + mockDiscoveredModel({ + provider: "custom", + modelId: "listed-model", + templateModel: { + ...makeModel("listed-model"), + provider: "custom", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + }, + }); + + const result = resolveModel("custom", "listed-model", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + "X-Static": "tenant-a", + }); + }); + it("prefers matching configured model metadata for fallback token limits", () => { const cfg = { models: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index b846895d029..f1b31a5e49a 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -5,6 +5,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; +import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { normalizeModelCompat } from "../model-compat.js"; import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; @@ -19,9 +20,29 @@ type InlineProviderConfig = { baseUrl?: string; api?: ModelDefinitionConfig["api"]; models?: ModelDefinitionConfig[]; - headers?: Record; + headers?: unknown; }; +function sanitizeModelHeaders( + headers: unknown, + opts?: { stripSecretRefMarkers?: boolean }, +): Record | undefined { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return undefined; + } + const next: Record = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== "string") { + continue; + } + if (opts?.stripSecretRefMarkers && isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + next[headerName] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +} + export { buildModelAliasLines }; function resolveConfiguredProviderConfig( @@ -46,16 +67,23 @@ function applyConfiguredProviderOverrides(params: { }): Model { const { discoveredModel, providerConfig, modelId } = params; if (!providerConfig) { - return discoveredModel; + return { + ...discoveredModel, + // Discovered models originate from models.json and may contain persistence markers. + headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }), + }; } const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId); - if ( - !configuredModel && - !providerConfig.baseUrl && - !providerConfig.api && - !providerConfig.headers - ) { - return discoveredModel; + const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, { + stripSecretRefMarkers: true, + }); + const providerHeaders = sanitizeModelHeaders(providerConfig.headers); + const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers); + if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) { + return { + ...discoveredModel, + headers: discoveredHeaders, + }; } return { ...discoveredModel, @@ -67,13 +95,13 @@ function applyConfiguredProviderOverrides(params: { contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow, maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens, headers: - providerConfig.headers || configuredModel?.headers + discoveredHeaders || providerHeaders || configuredHeaders ? { - ...discoveredModel.headers, - ...providerConfig.headers, - ...configuredModel?.headers, + ...discoveredHeaders, + ...providerHeaders, + ...configuredHeaders, } - : discoveredModel.headers, + : undefined, compat: configuredModel?.compat ?? discoveredModel.compat, }; } @@ -86,15 +114,22 @@ export function buildInlineProviderModels( if (!trimmed) { return []; } + const providerHeaders = sanitizeModelHeaders(entry?.headers); return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed, baseUrl: entry?.baseUrl, api: model.api ?? entry?.api, - headers: - entry?.headers || (model as InlineModelEntry).headers - ? { ...entry?.headers, ...(model as InlineModelEntry).headers } - : undefined, + headers: (() => { + const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers); + if (!providerHeaders && !modelHeaders) { + return undefined; + } + return { + ...providerHeaders, + ...modelHeaders, + }; + })(), })); }); } @@ -161,6 +196,8 @@ export function resolveModelWithRegistry(params: { } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); + const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); + const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); if (providerConfig || modelId.startsWith("mock-")) { return normalizeModelCompat({ id: modelId, @@ -180,9 +217,7 @@ export function resolveModelWithRegistry(params: { providerConfig?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, headers: - providerConfig?.headers || configuredModel?.headers - ? { ...providerConfig?.headers, ...configuredModel?.headers } - : undefined, + providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, } as Model); } diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index f6ae74d909d..07b40069d57 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as fences from "../markdown/fences.js"; import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; import { chunkByNewline, @@ -217,6 +218,17 @@ describe("chunkMarkdownText", () => { expect(chunks[0]?.length).toBe(20); expect(chunks.join("")).toBe(text); }); + + it("parses fence spans once for long fenced payloads", () => { + const parseSpy = vi.spyOn(fences, "parseFenceSpans"); + const text = `\`\`\`txt\n${"line\n".repeat(600)}\`\`\``; + + const chunks = chunkMarkdownText(text, 80); + + expect(chunks.length).toBeGreaterThan(2); + expect(parseSpy).toHaveBeenCalledTimes(1); + parseSpy.mockRestore(); + }); }); describe("chunkByNewline", () => { diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 780d57a1f5b..9d16f36d532 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -306,7 +306,7 @@ export function chunkText(text: string, limit: number): string[] { } return chunkTextByBreakResolver(text, limit, (window) => { // 1) Prefer a newline break inside the window (outside parentheses). - const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window); + const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, 0, window.length); // 2) Otherwise prefer the last whitespace (word boundary) inside the window. return lastNewline > 0 ? lastNewline : lastWhitespace; }); @@ -319,14 +319,24 @@ export function chunkMarkdownText(text: string, limit: number): string[] { } const chunks: string[] = []; - let remaining = text; + const spans = parseFenceSpans(text); + let start = 0; + let reopenFence: ReturnType | undefined; - while (remaining.length > limit) { - const spans = parseFenceSpans(remaining); - const window = remaining.slice(0, limit); + while (start < text.length) { + const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; + const contentLimit = Math.max(1, limit - reopenPrefix.length); + if (text.length - start <= contentLimit) { + const finalChunk = `${reopenPrefix}${text.slice(start)}`; + if (finalChunk.length > 0) { + chunks.push(finalChunk); + } + break; + } - const softBreak = pickSafeBreakIndex(window, spans); - let breakIdx = softBreak > 0 ? softBreak : limit; + const windowEnd = Math.min(text.length, start + contentLimit); + const softBreak = pickSafeBreakIndex(text, start, windowEnd, spans); + let breakIdx = softBreak > start ? softBreak : windowEnd; const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined @@ -335,38 +345,38 @@ export function chunkMarkdownText(text: string, limit: number): string[] { let fenceToSplit = initialFence; if (initialFence) { const closeLine = `${initialFence.indent}${initialFence.marker}`; - const maxIdxIfNeedNewline = limit - (closeLine.length + 1); + const maxIdxIfNeedNewline = start + (contentLimit - (closeLine.length + 1)); - if (maxIdxIfNeedNewline <= 0) { + if (maxIdxIfNeedNewline <= start) { fenceToSplit = undefined; - breakIdx = limit; + breakIdx = windowEnd; } else { const minProgressIdx = Math.min( - remaining.length, - initialFence.start + initialFence.openLine.length + 2, + text.length, + Math.max(start + 1, initialFence.start + initialFence.openLine.length + 2), ); - const maxIdxIfAlreadyNewline = limit - closeLine.length; + const maxIdxIfAlreadyNewline = start + (contentLimit - closeLine.length); let pickedNewline = false; - let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1)); - while (lastNewline !== -1) { + let lastNewline = text.lastIndexOf("\n", Math.max(start, maxIdxIfAlreadyNewline - 1)); + while (lastNewline >= start) { const candidateBreak = lastNewline + 1; if (candidateBreak < minProgressIdx) { break; } const candidateFence = findFenceSpanAt(spans, candidateBreak); if (candidateFence && candidateFence.start === initialFence.start) { - breakIdx = Math.max(1, candidateBreak); + breakIdx = candidateBreak; pickedNewline = true; break; } - lastNewline = remaining.lastIndexOf("\n", lastNewline - 1); + lastNewline = text.lastIndexOf("\n", lastNewline - 1); } if (!pickedNewline) { if (minProgressIdx > maxIdxIfAlreadyNewline) { fenceToSplit = undefined; - breakIdx = limit; + breakIdx = windowEnd; } else { breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline); } @@ -378,68 +388,72 @@ export function chunkMarkdownText(text: string, limit: number): string[] { fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined; } - let rawChunk = remaining.slice(0, breakIdx); - if (!rawChunk) { + const rawContent = text.slice(start, breakIdx); + if (!rawContent) { break; } - const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); - const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); - let next = remaining.slice(nextStart); + let rawChunk = `${reopenPrefix}${rawContent}`; + const brokeOnSeparator = breakIdx < text.length && /\s/.test(text[breakIdx]); + let nextStart = Math.min(text.length, breakIdx + (brokeOnSeparator ? 1 : 0)); if (fenceToSplit) { const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`; rawChunk = rawChunk.endsWith("\n") ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`; - next = `${fenceToSplit.openLine}\n${next}`; + reopenFence = fenceToSplit; } else { - next = stripLeadingNewlines(next); + nextStart = skipLeadingNewlines(text, nextStart); + reopenFence = undefined; } chunks.push(rawChunk); - remaining = next; - } - - if (remaining.length) { - chunks.push(remaining); + start = nextStart; } return chunks; } -function stripLeadingNewlines(value: string): string { - let i = 0; +function skipLeadingNewlines(value: string, start = 0): number { + let i = start; while (i < value.length && value[i] === "\n") { i++; } - return i > 0 ? value.slice(i) : value; + return i; } -function pickSafeBreakIndex(window: string, spans: ReturnType): number { - const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) => +function pickSafeBreakIndex( + text: string, + start: number, + end: number, + spans: ReturnType, +): number { + const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(text, start, end, (index) => isSafeFenceBreak(spans, index), ); - if (lastNewline > 0) { + if (lastNewline > start) { return lastNewline; } - if (lastWhitespace > 0) { + if (lastWhitespace > start) { return lastWhitespace; } return -1; } function scanParenAwareBreakpoints( - window: string, + text: string, + start: number, + end: number, isAllowed: (index: number) => boolean = () => true, ): { lastNewline: number; lastWhitespace: number } { let lastNewline = -1; let lastWhitespace = -1; let depth = 0; - for (let i = 0; i < window.length; i++) { + for (let i = start; i < end; i++) { if (!isAllowed(i)) { continue; } - const char = window[i]; + const char = text[i]; if (char === "(") { depth += 1; continue; diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index e4b9b7af561..13c79dc796d 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -196,6 +196,31 @@ function extractConfigAllowlist(account: { }; } +async function updatePairingStoreAllowlist(params: { + action: "add" | "remove"; + channelId: ChannelId; + accountId?: string; + entry: string; +}) { + const storeEntry = { + channel: params.channelId, + entry: params.entry, + accountId: params.accountId, + }; + if (params.action === "add") { + await addChannelAllowFromStoreEntry(storeEntry); + return; + } + + await removeChannelAllowFromStoreEntry(storeEntry); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + await removeChannelAllowFromStoreEntry({ + channel: params.channelId, + entry: params.entry, + }); + } +} + function resolveAccountTarget( parsed: Record, channelId: ChannelId, @@ -695,11 +720,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } if (shouldTouchStore) { - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); } const actionLabel = parsed.action === "add" ? "added" : "removed"; @@ -727,11 +753,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); const actionLabel = parsed.action === "add" ? "added" : "removed"; const scopeLabel = scope === "dm" ? "DM" : "group"; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index a83727d2daf..4d31b56a605 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -704,10 +704,74 @@ describe("handleCommands /allowlist", () => { expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ channel: "telegram", entry: "789", + accountId: "default", }); expect(result.reply?.text).toContain("DM allowlist added"); }); + it("writes store entries to the selected account scope", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, { + AccountId: "work", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "work", + }); + }); + + it("removes default-account entries from scoped and legacy pairing stores", async () => { + removeChannelAllowFromStoreEntryMock + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }) + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist remove dm --store 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, { + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, { + channel: "telegram", + entry: "789", + }); + }); + it("rejects blocked account ids and keeps Object.prototype clean", async () => { delete (Object.prototype as Record).allowFrom; diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index 6e5d42cf19d..88f8aa524c6 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -46,6 +46,26 @@ async function inspectUnknownListenerFallback(params: { }); } +async function inspectAmbiguousOwnershipWithProbe( + probeResult: Awaited>, +) { + const service = { + readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), + } as unknown as GatewayService; + + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ commandLine: "" }], + hints: [], + }); + classifyPortListener.mockReturnValue("unknown"); + probeGateway.mockResolvedValue(probeResult); + + const { inspectGatewayRestart } = await import("./restart-health.js"); + return inspectGatewayRestart({ service, port: 18789 }); +} + describe("inspectGatewayRestart", () => { beforeEach(() => { inspectPortUsage.mockReset(); @@ -159,25 +179,11 @@ describe("inspectGatewayRestart", () => { }); it("uses a local gateway probe when ownership is ambiguous", async () => { - const service = { - readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), - } as unknown as GatewayService; - - inspectPortUsage.mockResolvedValue({ - port: 18789, - status: "busy", - listeners: [{ commandLine: "" }], - hints: [], - }); - classifyPortListener.mockReturnValue("unknown"); - probeGateway.mockResolvedValue({ + const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: true, close: null, }); - const { inspectGatewayRestart } = await import("./restart-health.js"); - const snapshot = await inspectGatewayRestart({ service, port: 18789 }); - expect(snapshot.healthy).toBe(true); expect(probeGateway).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789" }), @@ -185,25 +191,11 @@ describe("inspectGatewayRestart", () => { }); it("treats auth-closed probe as healthy gateway reachability", async () => { - const service = { - readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), - } as unknown as GatewayService; - - inspectPortUsage.mockResolvedValue({ - port: 18789, - status: "busy", - listeners: [{ commandLine: "" }], - hints: [], - }); - classifyPortListener.mockReturnValue("unknown"); - probeGateway.mockResolvedValue({ + const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: false, close: { code: 1008, reason: "auth required" }, }); - const { inspectGatewayRestart } = await import("./restart-health.js"); - const snapshot = await inspectGatewayRestart({ service, port: 18789 }); - expect(snapshot.healthy).toBe(true); }); }); diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index d5dd4b8b727..9beee4b0010 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -124,6 +124,41 @@ function mockAcpManager(params: { } as unknown as ReturnType); } +async function withAcpSessionEnv(fn: () => Promise) { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + writeAcpSessionStore(storePath); + mockConfig(home, storePath); + await fn(); + }); +} + +function createRunTurnFromTextDeltas(chunks: string[]) { + return vi.fn(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; + }; + for (const text of chunks) { + await params.onEvent?.({ type: "text_delta", text }); + } + await params.onEvent?.({ type: "done", stopReason: "stop" }); + }); +} + +function subscribeAssistantEvents() { + const assistantEvents: Array<{ text?: string; delta?: string }> = []; + const stop = onAgentEvent((evt) => { + if (evt.stream !== "assistant") { + return; + } + assistantEvents.push({ + text: typeof evt.data?.text === "string" ? evt.data.text : undefined, + delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, + }); + }); + return { assistantEvents, stop }; +} + async function runAcpSessionWithPolicyOverrides(params: { acpOverrides: Partial>; resolveSession?: Parameters[0]["resolveSession"]; @@ -161,19 +196,8 @@ describe("agentCommand ACP runtime routing", () => { }); it("routes ACP sessions through AcpSessionManager instead of embedded agent", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - await params.onEvent?.({ type: "text_delta", text: "ACP_" }); - await params.onEvent?.({ type: "text_delta", text: "OK" }); - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -197,31 +221,15 @@ describe("agentCommand ACP runtime routing", () => { }); it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { - if (evt.stream !== "assistant") { - return; - } - assistantEvents.push({ - text: typeof evt.data?.text === "string" ? evt.data.text : undefined, - delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, - }); - }); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY", "Actual answer"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas([ + "NO", + "NO_", + "NO_RE", + "NO_REPLY", + "Actual answer", + ]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -242,11 +250,7 @@ describe("agentCommand ACP runtime routing", () => { }); it("keeps silent-only ACP turns out of assistant output", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - + await withAcpSessionEnv(async () => { const assistantEvents: string[] = []; const stop = onAgentEvent((evt) => { if (evt.stream !== "assistant") { @@ -257,15 +261,7 @@ describe("agentCommand ACP runtime routing", () => { } }); - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + const runTurn = createRunTurnFromTextDeltas(["NO", "NO_", "NO_RE", "NO_REPLY"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -286,31 +282,9 @@ describe("agentCommand ACP runtime routing", () => { }); it("preserves repeated identical ACP delta chunks", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { - if (evt.stream !== "assistant") { - return; - } - assistantEvents.push({ - text: typeof evt.data?.text === "string" ? evt.data.text : undefined, - delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, - }); - }); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["b", "o", "o", "k"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas(["b", "o", "o", "k"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -335,31 +309,9 @@ describe("agentCommand ACP runtime routing", () => { }); it("re-emits buffered NO prefix when ACP text becomes visible content", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { - if (evt.stream !== "assistant") { - return; - } - assistantEvents.push({ - text: typeof evt.data?.text === "string" ? evt.data.text : undefined, - delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, - }); - }); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["NO", "W"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas(["NO", "W"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 7ca6909af4a..baa58df2ef1 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -8,6 +8,7 @@ import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import * as sessionsModule from "../config/sessions.js"; @@ -51,6 +52,8 @@ const runtime: RuntimeEnv = { }; const configSpy = vi.spyOn(configModule, "loadConfig"); +const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite"); +const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot"); const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); const deliverAgentCommandResultSpy = vi.spyOn(agentDeliveryModule, "deliverAgentCommandResult"); @@ -256,13 +259,91 @@ function createTelegramOutboundPlugin() { beforeEach(() => { vi.clearAllMocks(); + configModule.clearRuntimeConfigSnapshot(); runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never); vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: false, resolved: {} as OpenClawConfig }, + writeOptions: {}, + } as Awaited>); }); describe("agentCommand", () => { + it("sets runtime snapshots from source config before embedded agent run", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const loadedConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { "anthropic/claude-opus-4-5": {} }, + workspace: path.join(home, "openclaw"), + }, + }, + session: { store, mainKey: "main" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const sourceConfig = { + ...loadedConfig, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const resolvedConfig = { + ...loadedConfig, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-resolved-runtime", // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + configSpy.mockReturnValue(loadedConfig); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + } as Awaited>); + const resolveSecretsSpy = vi + .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") + .mockResolvedValueOnce({ + resolvedConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + + await agentCommand({ message: "hello", to: "+1555" }, runtime); + + expect(resolveSecretsSpy).toHaveBeenCalledWith({ + config: loadedConfig, + commandName: "agent", + targetIds: expect.any(Set), + }); + expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig); + }); + }); + it("creates a session entry when deriving from --to", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 10582521b95..7ed147dd46f 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -57,7 +57,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; -import { loadConfig } from "../config/config.js"; +import { + loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, +} from "../config/config.js"; import { mergeSessionEntry, parseSessionThreadInfo, @@ -427,11 +431,23 @@ async function agentCommandInternal( } const loadedRaw = loadConfig(); + const sourceConfig = await (async () => { + try { + const { snapshot } = await readConfigFileSnapshotForWrite(); + if (snapshot.valid) { + return snapshot.resolved; + } + } catch { + // Fall back to runtime-loaded config when source snapshot is unavailable. + } + return loadedRaw; + })(); const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "agent", targetIds: getAgentRuntimeCommandSecretTargetIds(), }); + setRuntimeConfigSnapshot(cfg, sourceConfig); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); } diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 658eb9fd614..4bc01909098 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -186,26 +186,94 @@ const createTelegramPollPluginRegistration = () => ({ const { messageCommand } = await import("./message.js"); +function createTelegramSecretRawConfig() { + return { + channels: { + telegram: { + token: { $secret: "vault://telegram/token" }, + }, + }, + }; +} + +function createTelegramResolvedTokenConfig(token: string) { + return { + channels: { + telegram: { + token, + }, + }, + }; +} + +function mockResolvedCommandConfig(params: { + rawConfig: Record; + resolvedConfig: Record; + diagnostics?: string[]; +}) { + testConfig = params.rawConfig; + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: params.resolvedConfig, + diagnostics: params.diagnostics ?? ["resolved channels.telegram.token"], + }); +} + +async function runTelegramDirectOutboundSend(params: { + rawConfig: Record; + resolvedConfig: Record; + diagnostics?: string[]; +}) { + mockResolvedCommandConfig(params); + const sendText = vi.fn(async (_ctx: { cfg?: unknown; to?: string; text?: string }) => ({ + channel: "telegram" as const, + messageId: "msg-1", + chatId: "123456", + })); + const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ + channel: "telegram" as const, + messageId: "msg-2", + chatId: "123456", + })); + await setRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText, + sendMedia, + }, + }), + }, + ]), + ); + + const deps = makeDeps(); + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + }, + deps, + runtime, + ); + + return { sendText }; +} + describe("messageCommand", () => { it("threads resolved SecretRef config into outbound send actions", async () => { - const rawConfig = { - channels: { - telegram: { - token: { $secret: "vault://telegram/token" }, - }, - }, - }; - const resolvedConfig = { - channels: { - telegram: { - token: "12345:resolved-token", - }, - }, - }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const rawConfig = createTelegramSecretRawConfig(); + const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token"); + mockResolvedCommandConfig({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, - diagnostics: ["resolved channels.telegram.token"], }); await setRegistry( createTestRegistry([ @@ -240,64 +308,12 @@ describe("messageCommand", () => { }); it("threads resolved SecretRef config into outbound adapter sends", async () => { - const rawConfig = { - channels: { - telegram: { - token: { $secret: "vault://telegram/token" }, - }, - }, - }; - const resolvedConfig = { - channels: { - telegram: { - token: "12345:resolved-token", - }, - }, - }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const rawConfig = createTelegramSecretRawConfig(); + const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token"); + const { sendText } = await runTelegramDirectOutboundSend({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, - diagnostics: ["resolved channels.telegram.token"], }); - const sendText = vi.fn(async (_ctx: { cfg?: unknown; to: string; text: string }) => ({ - channel: "telegram" as const, - messageId: "msg-1", - chatId: "123456", - })); - const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-2", - chatId: "123456", - })); - await setRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - outbound: { - deliveryMode: "direct", - sendText, - sendMedia, - }, - }), - }, - ]), - ); - - const deps = makeDeps(); - await messageCommand( - { - action: "send", - channel: "telegram", - target: "123456", - message: "hi", - }, - deps, - runtime, - ); expect(sendText).toHaveBeenCalledWith( expect.objectContaining({ @@ -324,50 +340,11 @@ describe("messageCommand", () => { }, }, }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const { sendText } = await runTelegramDirectOutboundSend({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: locallyResolvedConfig as unknown as Record, diagnostics: ["gateway secrets.resolve unavailable; used local resolver fallback."], }); - const sendText = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-3", - chatId: "123456", - })); - const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-4", - chatId: "123456", - })); - await setRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - outbound: { - deliveryMode: "direct", - sendText, - sendMedia, - }, - }), - }, - ]), - ); - - const deps = makeDeps(); - await messageCommand( - { - action: "send", - channel: "telegram", - target: "123456", - message: "hi", - }, - deps, - runtime, - ); expect(sendText).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 1469effeff1..53a112d0451 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -5,6 +5,11 @@ let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegis let toModelRow: typeof import("./models/list.registry.js").toModelRow; const loadConfig = vi.fn(); +const readConfigFileSnapshotForWrite = vi.fn().mockResolvedValue({ + snapshot: { valid: false, resolved: {} }, + writeOptions: {}, +}); +const setRuntimeConfigSnapshot = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent"); const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} }); @@ -29,6 +34,8 @@ vi.mock("../config/config.js", () => ({ CONFIG_PATH: "/tmp/openclaw.json", STATE_DIR: "/tmp/openclaw-state", loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, })); vi.mock("../agents/models-config.js", () => ({ @@ -84,8 +91,16 @@ vi.mock("../agents/pi-model-discovery.js", () => { }); vi.mock("../agents/pi-embedded-runner/model.js", () => ({ - resolveModel: () => { - throw new Error("resolveModel should not be called from models.list tests"); + resolveModelWithRegistry: ({ + provider, + modelId, + modelRegistry, + }: { + provider: string; + modelId: string; + modelRegistry: { find: (provider: string, id: string) => unknown }; + }) => { + return modelRegistry.find(provider, modelId); }, })); @@ -114,6 +129,13 @@ beforeEach(() => { modelRegistryState.getAllError = undefined; modelRegistryState.getAvailableError = undefined; listProfilesForProvider.mockReturnValue([]); + ensureOpenClawModelsJson.mockClear(); + readConfigFileSnapshotForWrite.mockClear(); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: false, resolved: {} }, + writeOptions: {}, + }); + setRuntimeConfigSnapshot.mockClear(); }); afterEach(() => { @@ -302,6 +324,35 @@ describe("models list/status", () => { await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); }); + it("loadModelRegistry persists using source config snapshot when provided", async () => { + modelRegistryState.models = [OPENAI_MODEL]; + modelRegistryState.available = [OPENAI_MODEL]; + const sourceConfig = { + models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret + }; + + await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never }); + + expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig); + }); + + it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => { + modelRegistryState.models = [OPENAI_MODEL]; + modelRegistryState.available = [OPENAI_MODEL]; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret + }; + + await loadModelRegistry(resolvedConfig as never); + + expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig); + }); + it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { const row = toModelRow({ model: makeGoogleAntigravityTemplate( diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index bc23ff9351c..98906ced281 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; describe("resolveProviderAuthOverview", () => { @@ -21,4 +22,52 @@ describe("resolveProviderAuthOverview", () => { expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)"); }); + + it("renders marker-backed models.json auth as marker detail", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: NON_ENV_SECRETREF_MARKER, + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + }); + + it("keeps env-var-shaped models.json values masked to avoid accidental plaintext exposure", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).not.toContain("marker("); + expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + }); }); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 0fc2f9828c5..28880415eeb 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -6,12 +6,19 @@ import { resolveAuthStorePathForDisplay, resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; import type { ProviderAuthOverview } from "./list.types.js"; +function formatMarkerOrSecret(value: string): string { + return isNonSecretApiKeyMarker(value, { includeEnvVarName: false }) + ? `marker(${value.trim()})` + : maskApiKey(value); +} + function formatProfileSecretLabel(params: { value: string | undefined; ref: { source: string; id: string } | undefined; @@ -19,7 +26,8 @@ function formatProfileSecretLabel(params: { }): string { const value = typeof params.value === "string" ? params.value.trim() : ""; if (value) { - return params.kind === "token" ? `token:${maskApiKey(value)}` : maskApiKey(value); + const display = formatMarkerOrSecret(value); + return params.kind === "token" ? `token:${display}` : display; } if (params.ref) { const refLabel = `ref(${params.ref.source}:${params.ref.id})`; @@ -108,7 +116,7 @@ export function resolveProviderAuthOverview(params: { }; } if (customKey) { - return { kind: "models.json", detail: maskApiKey(customKey) }; + return { kind: "models.json", detail: formatMarkerOrSecret(customKey) }; } return { kind: "missing", detail: "missing" }; })(); @@ -137,7 +145,7 @@ export function resolveProviderAuthOverview(params: { ...(customKey ? { modelsJson: { - value: maskApiKey(customKey), + value: formatMarkerOrSecret(customKey), source: `models.json: ${shortenHomePath(params.modelsPath)}`, }, } diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 2b2e8612782..4cef137d88a 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -2,11 +2,38 @@ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const printModelTable = vi.fn(); + const sourceConfig = { + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { + providers: { + openai: { + apiKey: "$OPENAI_API_KEY", // pragma: allowlist secret + }, + }, + }, + }; + const resolvedConfig = { + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { + providers: { + openai: { + apiKey: "sk-resolved-runtime-value", // pragma: allowlist secret + }, + }, + }, + }; return { loadConfig: vi.fn().mockReturnValue({ agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, models: { providers: {} }, }), + sourceConfig, + resolvedConfig, + loadModelsConfigWithSource: vi.fn().mockResolvedValue({ + sourceConfig, + resolvedConfig, + diagnostics: [], + }), ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), loadModelRegistry: vi .fn() @@ -58,6 +85,10 @@ vi.mock("./list.registry.js", async (importOriginal) => { }; }); +vi.mock("./load-config.js", () => ({ + loadModelsConfigWithSource: mocks.loadModelsConfigWithSource, +})); + vi.mock("./list.configured.js", () => ({ resolveConfiguredEntries: mocks.resolveConfiguredEntries, })); @@ -95,6 +126,16 @@ describe("modelsListCommand forward-compat", () => { expect(codex?.tags).not.toContain("missing"); }); + it("passes source config to model registry loading for persistence safety", async () => { + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, { + sourceConfig: mocks.sourceConfig, + }); + }); + it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [ diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 7e706469cea..afcd7b785d2 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -8,7 +8,7 @@ import { formatErrorWithStack } from "./list.errors.js"; import { loadModelRegistry, toModelRow } from "./list.registry.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; -import { loadModelsConfig } from "./load-config.js"; +import { loadModelsConfigWithSource } from "./load-config.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js"; export async function modelsListCommand( @@ -23,7 +23,10 @@ export async function modelsListCommand( ) { ensureFlagCompatibility(opts); const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js"); - const cfg = await loadModelsConfig({ commandName: "models list", runtime }); + const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({ + commandName: "models list", + runtime, + }); const authStore = ensureAuthProfileStore(); const providerFilter = (() => { const raw = opts.provider?.trim(); @@ -39,7 +42,7 @@ export async function modelsListCommand( let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { - const loaded = await loadModelRegistry(cfg); + const loaded = await loadModelRegistry(cfg, { sourceConfig }); modelRegistry = loaded.registry; models = loaded.models; availableKeys = loaded.availableKeys; diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index c3e754199a2..bc50a8c2fb6 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; @@ -39,6 +40,34 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { const { buildProbeTargets } = await import("./list.probe.js"); +async function buildAnthropicProbePlan(order: string[]) { + return buildProbeTargets({ + cfg: { + auth: { + order: { + anthropic: order, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); +} + +function expectLegacyMissingCredentialsError( + result: { reasonCode?: string; error?: string } | undefined, + reasonCode: string, +) { + expect(result?.reasonCode).toBe(reasonCode); + expect(result?.error?.split("\n")[0]).toBe("Auth profile credentials are missing or expired."); + expect(result?.error).toContain(`[${reasonCode}]`); +} + describe("buildProbeTargets reason codes", () => { beforeEach(() => { mockStore = { @@ -67,52 +96,18 @@ describe("buildProbeTargets reason codes", () => { }); it("reports invalid_expires with a legacy-compatible first error line", async () => { - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:default"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:default"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); - expect(plan.results[0]?.reasonCode).toBe("invalid_expires"); - expect(plan.results[0]?.error?.split("\n")[0]).toBe( - "Auth profile credentials are missing or expired.", - ); - expect(plan.results[0]?.error).toContain("[invalid_expires]"); + expectLegacyMissingCredentialsError(plan.results[0], "invalid_expires"); }); it("reports excluded_by_auth_order when profile id is not present in explicit order", async () => { mockStore.order = { anthropic: ["anthropic:work"], }; - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:work"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:work"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); @@ -137,30 +132,116 @@ describe("buildProbeTargets reason codes", () => { mockAllowedProfiles = ["anthropic:default"]; resolveSecretRefStringMock.mockRejectedValueOnce(new Error("missing secret")); - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:default"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:default"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); - expect(plan.results[0]?.reasonCode).toBe("unresolved_ref"); - expect(plan.results[0]?.error?.split("\n")[0]).toBe( - "Auth profile credentials are missing or expired.", - ); - expect(plan.results[0]?.error).toContain("[unresolved_ref]"); + expectLegacyMissingCredentialsError(plan.results[0], "unresolved_ref"); expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN"); }); + + it("skips marker-only models.json credentials when building probe targets", async () => { + const previousAnthropic = process.env.ANTHROPIC_API_KEY; + const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_OAUTH_TOKEN; + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + try { + const plan = await buildProbeTargets({ + cfg: { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + api: "anthropic-messages", + apiKey: OLLAMA_LOCAL_AUTH_MARKER, + models: [], + }, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + + expect(plan.targets).toEqual([]); + expect(plan.results).toEqual([]); + } finally { + if (previousAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropic; + } + if (previousAnthropicOauth === undefined) { + delete process.env.ANTHROPIC_OAUTH_TOKEN; + } else { + process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth; + } + } + }); + + it("does not treat arbitrary all-caps models.json apiKey values as markers", async () => { + const previousAnthropic = process.env.ANTHROPIC_API_KEY; + const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_OAUTH_TOKEN; + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + try { + const plan = await buildProbeTargets({ + cfg: { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + api: "anthropic-messages", + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + models: [], + }, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + + expect(plan.results).toEqual([]); + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]).toEqual( + expect.objectContaining({ + provider: "anthropic", + source: "models.json", + label: "models.json", + }), + ); + } finally { + if (previousAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropic; + } + if (previousAnthropicOauth === undefined) { + delete process.env.ANTHROPIC_OAUTH_TOKEN; + } else { + process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth; + } + } + }); }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 8a2ec87adcc..40eb6b99b9b 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -12,6 +12,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { describeFailoverError } from "../../agents/failover-error.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { @@ -373,7 +374,8 @@ export async function buildProbeTargets(params: { const envKey = resolveEnvApiKey(providerKey); const customKey = getCustomProviderApiKey(cfg, providerKey); - if (!envKey && !customKey) { + const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey)); + if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index a4fd2cdf0f5..187b55176f5 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -94,8 +94,13 @@ function loadAvailableModels(registry: ModelRegistry): Model[] { } } -export async function loadModelRegistry(cfg: OpenClawConfig) { - await ensureOpenClawModelsJson(cfg); +export async function loadModelRegistry( + cfg: OpenClawConfig, + opts?: { sourceConfig?: OpenClawConfig }, +) { + // Persistence must be based on source config (pre-resolution) so SecretRef-managed + // credentials remain markers in models.json for command paths too. + await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); diff --git a/src/commands/models/load-config.test.ts b/src/commands/models/load-config.test.ts new file mode 100644 index 00000000000..b8969fd4681 --- /dev/null +++ b/src/commands/models/load-config.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + readConfigFileSnapshotForWrite: vi.fn(), + setRuntimeConfigSnapshot: vi.fn(), + resolveCommandSecretRefsViaGateway: vi.fn(), + getModelsCommandSecretTargetIds: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot: mocks.setRuntimeConfigSnapshot, +})); + +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("../../cli/command-secret-targets.js", () => ({ + getModelsCommandSecretTargetIds: mocks.getModelsCommandSecretTargetIds, +})); + +import { loadModelsConfig, loadModelsConfigWithSource } from "./load-config.js"; + +describe("models load-config", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns source+resolved configs and sets runtime snapshot", async () => { + const sourceConfig = { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + }; + const runtimeConfig = { + models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret + }; + const targetIds = new Set(["models.providers.*.apiKey"]); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + }); + mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: ["diag-one", "diag-two"], + }); + + const result = await loadModelsConfigWithSource({ commandName: "models list", runtime }); + + expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({ + config: runtimeConfig, + commandName: "models list", + targetIds, + }); + expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(runtime.log).toHaveBeenNthCalledWith(1, "[secrets] diag-one"); + expect(runtime.log).toHaveBeenNthCalledWith(2, "[secrets] diag-two"); + expect(result).toEqual({ + sourceConfig, + resolvedConfig, + diagnostics: ["diag-one", "diag-two"], + }); + }); + + it("loadModelsConfig returns resolved config while preserving runtime snapshot behavior", async () => { + const sourceConfig = { models: { providers: {} } }; + const runtimeConfig = { + models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret + }; + const targetIds = new Set(["models.providers.*.apiKey"]); + + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + }); + mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [], + }); + + await expect(loadModelsConfig({ commandName: "models list" })).resolves.toBe(resolvedConfig); + expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + }); +}); diff --git a/src/commands/models/load-config.ts b/src/commands/models/load-config.ts index ead48fa8b8a..854cd5240da 100644 --- a/src/commands/models/load-config.ts +++ b/src/commands/models/load-config.ts @@ -1,15 +1,39 @@ import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; -import { loadConfig, type OpenClawConfig } from "../../config/config.js"; +import { + loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -export async function loadModelsConfig(params: { +export type LoadedModelsConfig = { + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + diagnostics: string[]; +}; + +async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise { + try { + const { snapshot } = await readConfigFileSnapshotForWrite(); + if (snapshot.valid) { + return snapshot.resolved; + } + } catch { + // Fall back to runtime-loaded config if source snapshot cannot be read. + } + return fallback; +} + +export async function loadModelsConfigWithSource(params: { commandName: string; runtime?: RuntimeEnv; -}): Promise { - const loadedRaw = loadConfig(); +}): Promise { + const runtimeConfig = loadConfig(); + const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig); const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, + config: runtimeConfig, commandName: params.commandName, targetIds: getModelsCommandSecretTargetIds(), }); @@ -18,5 +42,17 @@ export async function loadModelsConfig(params: { params.runtime.log(`[secrets] ${entry}`); } } - return resolvedConfig; + setRuntimeConfigSnapshot(resolvedConfig, sourceConfig); + return { + sourceConfig, + resolvedConfig, + diagnostics, + }; +} + +export async function loadModelsConfig(params: { + commandName: string; + runtime?: RuntimeEnv; +}): Promise { + return (await loadModelsConfigWithSource(params)).resolvedConfig; } diff --git a/src/commands/onboard-config.test.ts b/src/commands/onboard-config.test.ts index 076f98a02f1..c5997345fe7 100644 --- a/src/commands/onboard-config.test.ts +++ b/src/commands/onboard-config.test.ts @@ -7,6 +7,10 @@ import { } from "./onboard-config.js"; describe("applyOnboardingLocalWorkspaceConfig", () => { + it("defaults local onboarding tool profile to coding", () => { + expect(ONBOARDING_DEFAULT_TOOLS_PROFILE).toBe("coding"); + }); + it("sets secure dmScope default when unset", () => { const baseConfig: OpenClawConfig = {}; const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index f2ae8991141..62b1006283e 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -3,7 +3,7 @@ import type { DmScope } from "../config/types.base.js"; import type { ToolProfileId } from "../config/types.tools.js"; export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer"; -export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "messaging"; +export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "coding"; export function applyOnboardingLocalWorkspaceConfig( baseConfig: OpenClawConfig, diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 1d9e8bc5881..c5d29a12177 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -145,7 +145,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }>(configPath); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); - expect(cfg?.tools?.profile).toBe("messaging"); + expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); }); diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index e1f77bfff68..69995eef3d7 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -34,6 +34,44 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): { return { prompter, notes }; } +function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConfig { + return { + tools: { + web: { + search: { + provider: "perplexity", + ...(enabled === undefined ? {} : { enabled }), + perplexity: { apiKey }, + }, + }, + }, + }; +} + +async function runBlankPerplexityKeyEntry( + apiKey: string, + enabled?: boolean, +): Promise { + const cfg = createPerplexityConfig(apiKey, enabled); + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "", + }); + return setupSearch(cfg, runtime, prompter); +} + +async function runQuickstartPerplexitySetup( + apiKey: string, + enabled?: boolean, +): Promise<{ result: OpenClawConfig; prompter: WizardPrompter }> { + const cfg = createPerplexityConfig(apiKey, enabled); + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + return { result, prompter }; +} + describe("setupSearch", () => { it("returns config unchanged when user skips", async () => { const cfg: OpenClawConfig = {}; @@ -103,74 +141,49 @@ describe("setupSearch", () => { }); it("shows missing-key note when no key is provided and no env var", async () => { - const cfg: OpenClawConfig = {}; - const { prompter, notes } = createPrompter({ - selectValue: "brave", - textValue: "", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("brave"); - expect(result.tools?.web?.search?.enabled).toBeUndefined(); - const missingNote = notes.find((n) => n.message.includes("No API key stored")); - expect(missingNote).toBeDefined(); + const original = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + try { + const cfg: OpenClawConfig = {}; + const { prompter, notes } = createPrompter({ + selectValue: "brave", + textValue: "", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + const missingNote = notes.find((n) => n.message.includes("No API key stored")); + expect(missingNote).toBeDefined(); + } finally { + if (original === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = original; + } + } }); it("keeps existing key when user leaves input blank", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { apiKey: "existing-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ - selectValue: "perplexity", - textValue: "", - }); - const result = await setupSearch(cfg, runtime, prompter); + const result = await runBlankPerplexityKeyEntry( + "existing-key", // pragma: allowlist secret + ); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); expect(result.tools?.web?.search?.enabled).toBe(true); }); it("advanced preserves enabled:false when keeping existing key", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - enabled: false, - perplexity: { apiKey: "existing-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ - selectValue: "perplexity", - textValue: "", - }); - const result = await setupSearch(cfg, runtime, prompter); + const result = await runBlankPerplexityKeyEntry( + "existing-key", // pragma: allowlist secret + false, + ); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); expect(result.tools?.web?.search?.enabled).toBe(false); }); it("quickstart skips key prompt when config key exists", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { apiKey: "stored-pplx-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { - quickstartDefaults: true, - }); + const { result, prompter } = await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + ); expect(result.tools?.web?.search?.provider).toBe("perplexity"); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(true); @@ -178,21 +191,10 @@ describe("setupSearch", () => { }); it("quickstart preserves enabled:false when search was intentionally disabled", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - enabled: false, - perplexity: { apiKey: "stored-pplx-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { - quickstartDefaults: true, - }); + const { result, prompter } = await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + false, + ); expect(result.tools?.web?.search?.provider).toBe("perplexity"); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(false); @@ -200,14 +202,24 @@ describe("setupSearch", () => { }); it("quickstart falls through to key prompt when no key and no env var", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ selectValue: "grok", textValue: "" }); - const result = await setupSearch(cfg, runtime, prompter, { - quickstartDefaults: true, - }); - expect(prompter.text).toHaveBeenCalled(); - expect(result.tools?.web?.search?.provider).toBe("grok"); - expect(result.tools?.web?.search?.enabled).toBeUndefined(); + const original = process.env.XAI_API_KEY; + delete process.env.XAI_API_KEY; + try { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "grok", textValue: "" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("grok"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + } finally { + if (original === undefined) { + delete process.env.XAI_API_KEY; + } else { + process.env.XAI_API_KEY = original; + } + } }); it("quickstart skips key prompt when env var is available", async () => { diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 6d25e4c6d16..92a4769c1fd 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -154,6 +154,35 @@ describe("config identity defaults", () => { }); }); + it("accepts SecretRef values in model provider headers", async () => { + await withTempHome("openclaw-config-identity-", async (home) => { + const cfg = await writeAndLoadConfig(home, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }, + }, + models: [], + }, + }, + }, + }); + + expect(cfg.models?.providers?.openai?.headers?.Authorization).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }); + }); + }); + it("respects empty responsePrefix to disable identity defaults", async () => { await withTempHome("openclaw-config-identity-", async (home) => { const cfg = await writeAndLoadConfig(home, configWithDefaultIdentity({ responsePrefix: "" })); diff --git a/src/config/config.ts b/src/config/config.ts index dfe47d82f87..35fe656c666 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,6 +3,7 @@ export { clearRuntimeConfigSnapshot, createConfigIO, getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, parseConfigJson5, readConfigFileSnapshot, diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index 0a37de08aaa..cca75174500 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -5,6 +5,7 @@ import { withTempHome } from "./home-env.test-harness.js"; import { clearConfigCache, clearRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, setRuntimeConfigSnapshot, writeConfigFile, @@ -12,6 +13,70 @@ import { import type { OpenClawConfig } from "./types.js"; describe("runtime config snapshot writes", () => { + it("returns the source snapshot when runtime snapshot is active", async () => { + await withTempHome("openclaw-config-runtime-source-", async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + models: [], + }, + }, + }, + }; + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + expect(getRuntimeConfigSourceSnapshot()).toEqual(sourceConfig); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("clears runtime source snapshot when runtime snapshot is cleared", async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", + models: [], + }, + }, + }, + }; + + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + clearRuntimeConfigSnapshot(); + clearConfigCache(); + expect(getRuntimeConfigSourceSnapshot()).toBeNull(); + }); + it("preserves source secret refs when writeConfigFile receives runtime-resolved config", async () => { await withTempHome("openclaw-config-runtime-write-", async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/io.ts b/src/config/io.ts index a2a2af5d1b5..d8b90646d12 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1345,6 +1345,10 @@ export function getRuntimeConfigSnapshot(): OpenClawConfig | null { return runtimeConfigSnapshot; } +export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { + return runtimeConfigSourceSnapshot; +} + export function loadConfig(): OpenClawConfig { if (runtimeConfigSnapshot) { return runtimeConfigSnapshot; diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index ab685448fdf..f660af8831e 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -775,6 +775,9 @@ describe("config help copy quality", () => { it("documents auth/model root semantics and provider secret handling", () => { const providerKey = FIELD_HELP["models.providers.*.apiKey"]; expect(/secret|env|credential/i.test(providerKey)).toBe(true); + const modelsMode = FIELD_HELP["models.mode"]; + expect(modelsMode.includes("SecretRef-managed")).toBe(true); + expect(modelsMode.includes("preserve")).toBe(true); const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"]; expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f13944cb127..f0d30c854e7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -688,7 +688,7 @@ export const FIELD_HELP: Record = { models: "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "models.mode": - 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing; matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', + 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', "models.providers": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "models.providers.*.baseUrl": diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 41ac8b1aa5d..e21a330f2e6 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -135,6 +135,7 @@ describe("mapSensitivePaths", () => { expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true); expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true); expect(hints["gateway.auth.token"]?.sensitive).toBe(true); + expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); }); diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 4ef646cc48a..b881269d961 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -54,7 +54,7 @@ export type ModelProviderConfig = { auth?: ModelProviderAuthMode; api?: ModelApi; injectNumCtxForOpenAICompat?: boolean; - headers?: Record; + headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 733917e4dac..7ddef789282 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -234,7 +234,7 @@ export const ModelProviderSchema = z .optional(), api: ModelApiSchema.optional(), injectNumCtxForOpenAICompat: z.boolean().optional(), - headers: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), }) diff --git a/src/cron/isolated-agent/run.interim-retry.test.ts b/src/cron/isolated-agent/run.interim-retry.test.ts index 19f47bc8411..90d663ed020 100644 --- a/src/cron/isolated-agent/run.interim-retry.test.ts +++ b/src/cron/isolated-agent/run.interim-retry.test.ts @@ -17,6 +17,21 @@ const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); describe("runCronIsolatedAgentTurn — interim ack retry", () => { setupRunCronIsolatedAgentTurnSuite(); + const mockFallbackPassthrough = () => { + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + const result = await run(provider, model); + return { result, provider, model, attempts: [] }; + }); + }; + + const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => { + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); + expect(result.status).toBe("ok"); + expect(runWithModelFallbackMock).toHaveBeenCalledTimes(expectedFallbackCalls); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(expectedAgentCalls); + return result; + }; + const usePayloadTextExtraction = () => { pickLastNonEmptyTextFromPayloadsMock.mockImplementation( (payloads?: Array<{ text?: string }>) => { @@ -47,16 +62,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(2); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); + mockFallbackPassthrough(); + await runTurnAndExpectOk(2, 2); expect(runEmbeddedPiAgentMock.mock.calls[1]?.[0]?.prompt).toContain( "previous response was only an acknowledgement", ); @@ -69,16 +76,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + mockFallbackPassthrough(); + await runTurnAndExpectOk(1, 1); }); it("does not retry when descendants were spawned in this run even if they already settled", async () => { @@ -94,15 +93,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { ]); countActiveDescendantRunsMock.mockReturnValue(0); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + mockFallbackPassthrough(); + await runTurnAndExpectOk(1, 1); }); }); diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index e62e9e2e7ab..b0cf8778eb1 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -30,6 +30,22 @@ function resolveCachedCron(expr: string, timezone: string): Cron { return next; } +function resolveCronFromSchedule(schedule: { + tz?: string; + expr?: unknown; + cron?: unknown; +}): Cron | undefined { + const exprSource = typeof schedule.expr === "string" ? schedule.expr : schedule.cron; + if (typeof exprSource !== "string") { + throw new Error("invalid cron schedule: expr is required"); + } + const expr = exprSource.trim(); + if (!expr) { + return undefined; + } + return resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); +} + export function coerceFiniteScheduleNumber(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -81,16 +97,10 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe return anchor + steps * everyMs; } - const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; - const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; - if (typeof exprSource !== "string") { - throw new Error("invalid cron schedule: expr is required"); - } - const expr = exprSource.trim(); - if (!expr) { + const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown }); + if (!cron) { return undefined; } - const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); let next = cron.nextRun(new Date(nowMs)); if (!next) { return undefined; @@ -132,16 +142,10 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n if (schedule.kind !== "cron") { return undefined; } - const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; - const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; - if (typeof exprSource !== "string") { - throw new Error("invalid cron schedule: expr is required"); - } - const expr = exprSource.trim(); - if (!expr) { + const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown }); + if (!cron) { return undefined; } - const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); const previousRuns = cron.previousRuns(1, new Date(nowMs)); const previous = previousRuns[0]; if (!previous) { diff --git a/src/discord/monitor/message-handler.bot-self-filter.test.ts b/src/discord/monitor/message-handler.bot-self-filter.test.ts index 7f5b2276987..4358301b92d 100644 --- a/src/discord/monitor/message-handler.bot-self-filter.test.ts +++ b/src/discord/monitor/message-handler.bot-self-filter.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + DEFAULT_DISCORD_BOT_USER_ID, + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); @@ -15,53 +18,12 @@ vi.mock("./message-handler.process.js", () => ({ const { createDiscordMessageHandler } = await import("./message-handler.js"); -const BOT_USER_ID = "bot-123"; - -function createHandlerParams(overrides?: Partial<{ botUserId: string }>) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: overrides?.botUserId ?? BOT_USER_ID, - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - }; -} - function createMessageData(authorId: string, channelId = "ch-1") { return { - author: { id: authorId, bot: authorId === BOT_USER_ID }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, message: { id: "msg-1", - author: { id: authorId, bot: authorId === BOT_USER_ID }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, content: "hello", channel_id: channelId, }, @@ -70,26 +32,7 @@ function createMessageData(authorId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { - return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, - }; + return createDiscordPreflightContext(channelId); } describe("createDiscordMessageHandler bot-self filter", () => { @@ -97,10 +40,10 @@ describe("createDiscordMessageHandler bot-self filter", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect( - handler(createMessageData(BOT_USER_ID) as never, {} as never), + handler(createMessageData(DEFAULT_DISCORD_BOT_USER_ID) as never, {} as never), ).resolves.toBeUndefined(); expect(preflightDiscordMessageMock).not.toHaveBeenCalled(); @@ -115,7 +58,7 @@ describe("createDiscordMessageHandler bot-self filter", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect( handler(createMessageData("user-456") as never, {} as never), diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index ac2ab57e283..1e4d9c5dddb 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -27,6 +27,13 @@ type DiscordConfig = NonNullable< type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; type DiscordClient = import("@buape/carbon").Client; +const DEFAULT_CFG = { + session: { + mainKey: "main", + scope: "per-sender", + }, +} as import("../../config/config.js").OpenClawConfig; + function createThreadBinding( overrides?: Partial< import("../../infra/outbound/session-binding-service.js").SessionBindingRecord @@ -82,6 +89,154 @@ function createPreflightArgs(params: { }; } +function createGuildTextClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { + return { + fetchChannel: async (channelId: string) => { + if (channelId === params.threadId) { + return { + id: params.threadId, + type: ChannelType.PublicThread, + name: "focus", + parentId: params.parentId, + ownerId: "owner-1", + }; + } + if (channelId === params.parentId) { + return { + id: params.parentId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +function createGuildEvent(params: { + channelId: string; + guildId: string; + author: import("@buape/carbon").Message["author"]; + message: import("@buape/carbon").Message; +}): DiscordMessageEvent { + return { + channel_id: params.channelId, + guild_id: params.guildId, + guild: { + id: params.guildId, + name: "Guild One", + }, + author: params.author, + message: params.message, + } as unknown as DiscordMessageEvent; +} + +function createMessage(params: { + id: string; + channelId: string; + content: string; + author: { + id: string; + bot: boolean; + username?: string; + }; + mentionedUsers?: Array<{ id: string }>; + mentionedEveryone?: boolean; + attachments?: Array>; +}): import("@buape/carbon").Message { + return { + id: params.id, + content: params.content, + timestamp: new Date().toISOString(), + channelId: params.channelId, + attachments: params.attachments ?? [], + mentionedUsers: params.mentionedUsers ?? [], + mentionedRoles: [], + mentionedEveryone: params.mentionedEveryone ?? false, + author: params.author, + } as unknown as import("@buape/carbon").Message; +} + +async function runThreadBoundPreflight(params: { + threadId: string; + parentId: string; + message: import("@buape/carbon").Message; + threadBinding: import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; + discordConfig: DiscordConfig; + registerBindingAdapter?: boolean; +}) { + if (params.registerBindingAdapter) { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === params.threadId ? params.threadBinding : null, + }); + } + + const client = createThreadClient({ + threadId: params.threadId, + parentId: params.parentId, + }); + + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_CFG, + discordConfig: params.discordConfig, + data: createGuildEvent({ + channelId: params.threadId, + guildId: "guild-1", + author: params.message.author, + message: params.message, + }), + client, + }), + threadBindings: { + getByThreadId: (id: string) => (id === params.threadId ? params.threadBinding : undefined), + } as import("./thread-bindings.js").ThreadBindingManager, + }); +} + +async function runGuildPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; + discordConfig: DiscordConfig; + cfg?: import("../../config/config.js").OpenClawConfig; + guildEntries?: Parameters[0]["guildEntries"]; +}) { + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: params.cfg ?? DEFAULT_CFG, + discordConfig: params.discordConfig, + data: createGuildEvent({ + channelId: params.channelId, + guildId: params.guildId, + author: params.message.author, + message: params.message, + }), + client: createGuildTextClient(params.channelId), + }), + guildEntries: params.guildEntries, + }); +} + describe("resolvePreflightMentionRequirement", () => { it("requires mention when config requires mention and thread is not bound", () => { expect( @@ -124,81 +279,26 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-system-1"; const parentId = "channel-parent-1"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-system-1", + channelId: threadId, content: "⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.", - timestamp: new Date().toISOString(), - channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "relay-bot-1", bot: true, username: "OpenClaw", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, discordConfig: { allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: { - getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), - } as import("./thread-bindings.js").ThreadBindingManager, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).toBeNull(); @@ -211,87 +311,26 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-bot-regular-1"; const parentId = "channel-parent-regular-1"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-regular-1", - content: "here is tool output chunk", - timestamp: new Date().toISOString(), channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "here is tool output chunk", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; - - registerSessionBindingAdapter({ - channel: "discord", - accountId: "default", - listBySession: () => [], - resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null), }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, discordConfig: { allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: { - getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), - } as import("./thread-bindings.js").ThreadBindingManager, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, + registerBindingAdapter: true, }); expect(result).not.toBeNull(); @@ -302,42 +341,17 @@ describe("preflightDiscordMessage", () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; const parentId = "channel-parent-focus"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const client = createThreadClient({ threadId, parentId }); + const message = createMessage({ id: "m-bot-1", - content: "relay message without mention", - timestamp: new Date().toISOString(), channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "relay message without mention", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); registerSessionBindingAdapter({ channel: "discord", @@ -349,24 +363,17 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, + ...DEFAULT_CFG, } as import("../../config/config.js").OpenClawConfig, discordConfig: { allowBots: true, } as DiscordConfig, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", author: message.author, message, - } as unknown as DiscordMessageEvent, + }), client, }), ); @@ -379,69 +386,24 @@ describe("preflightDiscordMessage", () => { it("drops bot messages without mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-off"; const guildId = "guild-bot-mentions-off"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-mentions-off", - content: "relay chatter", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "relay chatter", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runGuildPreflight({ + channelId, + guildId, + message, discordConfig: { allowBots: "mentions", - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).toBeNull(); @@ -450,69 +412,25 @@ describe("preflightDiscordMessage", () => { it("allows bot messages with explicit mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-on"; const guildId = "guild-bot-mentions-on"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-mentions-on", - content: "hi <@openclaw-bot>", - timestamp: new Date().toISOString(), channelId, - attachments: [], + content: "hi <@openclaw-bot>", mentionedUsers: [{ id: "openclaw-bot" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runGuildPreflight({ + channelId, + guildId, + message, discordConfig: { allowBots: "mentions", - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).not.toBeNull(); @@ -521,75 +439,29 @@ describe("preflightDiscordMessage", () => { it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-other-mention-1", - content: "hello <@999>", - timestamp: new Date().toISOString(), channelId, - attachments: [], + content: "hello <@999>", mentionedUsers: [{ id: "999" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + const result = await runGuildPreflight({ + channelId, + guildId, + message, + discordConfig: {} as DiscordConfig, guildEntries: { [guildId]: { requireMention: false, ignoreOtherMentions: true, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).toBeNull(); @@ -598,75 +470,29 @@ describe("preflightDiscordMessage", () => { it("does not drop @everyone messages when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-everyone"; const guildId = "guild-other-mention-everyone"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-other-mention-everyone", - content: "@everyone heads up", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], + content: "@everyone heads up", mentionedEveryone: true, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + const result = await runGuildPreflight({ + channelId, + guildId, + message, + discordConfig: {} as DiscordConfig, guildEntries: { [guildId]: { requireMention: false, ignoreOtherMentions: true, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).not.toBeNull(); @@ -676,74 +502,38 @@ describe("preflightDiscordMessage", () => { it("ignores bot-sent @everyone mentions for detection", async () => { const channelId = "channel-everyone-1"; const guildId = "guild-everyone-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const client = createGuildTextClient(channelId); + const message = createMessage({ id: "m-everyone-1", - content: "@everyone heads up", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], + content: "@everyone heads up", mentionedEveryone: true, author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: { - allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + ...createPreflightArgs({ + cfg: DEFAULT_CFG, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: createGuildEvent({ + channelId, + guildId, + author: message.author, + message, + }), + client, + }), guildEntries: { [guildId]: { requireMention: false, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).not.toBeNull(); @@ -754,24 +544,12 @@ describe("preflightDiscordMessage", () => { transcribeFirstAudioMock.mockResolvedValue("hey openclaw"); const channelId = "channel-audio-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; + const client = createGuildTextClient(channelId); - const message = { + const message = createMessage({ id: "m-audio-1", - content: "", - timestamp: new Date().toISOString(), channelId, + content: "", attachments: [ { id: "att-1", @@ -780,23 +558,17 @@ describe("preflightDiscordMessage", () => { filename: "voice.ogg", }, ], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, + ...DEFAULT_CFG, messages: { groupChat: { mentionPatterns: ["openclaw"], @@ -804,16 +576,12 @@ describe("preflightDiscordMessage", () => { }, } as import("../../config/config.js").OpenClawConfig, discordConfig: {} as DiscordConfig, - data: { - channel_id: channelId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, + data: createGuildEvent({ + channelId, + guildId: "guild-1", author: message.author, message, - } as unknown as DiscordMessageEvent, + }), client, }), ); diff --git a/src/discord/monitor/message-handler.queue.test.ts b/src/discord/monitor/message-handler.queue.test.ts index 45fbfeee278..122ce852333 100644 --- a/src/discord/monitor/message-handler.queue.test.ts +++ b/src/discord/monitor/message-handler.queue.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn()); +type SetStatusFn = (patch: Record) => void; vi.mock("./message-handler.preflight.js", () => ({ preflightDiscordMessage: preflightDiscordMessageMock, @@ -24,52 +27,6 @@ function createDeferred() { return { promise, resolve }; } -function createHandlerParams(overrides?: { - setStatus?: (patch: Record) => void; - abortSignal?: AbortSignal; - workerRunTimeoutMs?: number; -}) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "bot-123", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2_000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - setStatus: overrides?.setStatus, - abortSignal: overrides?.abortSignal, - workerRunTimeoutMs: overrides?.workerRunTimeoutMs, - }; -} - function createMessageData(messageId: string, channelId = "ch-1") { return { channel_id: channelId, @@ -85,25 +42,43 @@ function createMessageData(messageId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { + return createDiscordPreflightContext(channelId); +} + +async function createLifecycleStopScenario(params: { + createHandler: (status: SetStatusFn) => { + handler: (data: never, opts: never) => Promise; + stop: () => void; + }; +}) { + const runInFlight = createDeferred(); + processDiscordMessageMock.mockImplementation(async () => { + await runInFlight.promise; + }); + preflightDiscordMessageMock.mockImplementation( + async (contextParams: { data: { channel_id: string } }) => + createPreflightContext(contextParams.data.channel_id), + ); + + const setStatus = vi.fn(); + const { handler, stop } = params.createHandler(setStatus); + + await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + + const callsBeforeStop = setStatus.mock.calls.length; + stop(); + return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, + setStatus, + callsBeforeStop, + finish: async () => { + runInFlight.resolve(); + await runInFlight.promise; + await Promise.resolve(); }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, }; } @@ -113,7 +88,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processDiscordMessageMock.mockReset(); const setStatus = vi.fn(); - createDiscordMessageHandler(createHandlerParams({ setStatus })); + createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); expect(setStatus).toHaveBeenCalledWith( expect.objectContaining({ @@ -142,7 +117,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); @@ -205,7 +180,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 50 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); const handler = createDiscordMessageHandler(params); await expect( @@ -256,7 +231,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 0 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 }); const handler = createDiscordMessageHandler(params); await expect( @@ -305,7 +280,7 @@ describe("createDiscordMessageHandler queue behavior", () => { try { const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect( handler(createMessageData("m-1") as never, {} as never), ).resolves.toBeUndefined(); @@ -342,67 +317,35 @@ describe("createDiscordMessageHandler queue behavior", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const abortController = new AbortController(); - const handler = createDiscordMessageHandler( - createHandlerParams({ setStatus, abortSignal: abortController.signal }), - ); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const abortController = new AbortController(); + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status, abortSignal: abortController.signal }), + ); + return { handler, stop: () => abortController.abort() }; + }, }); - const callsBeforeAbort = setStatus.mock.calls.length; - abortController.abort(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeAbort); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("stops status publishing after handler deactivation", async () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status }), + ); + return { handler, stop: () => handler.deactivate() }; + }, }); - const callsBeforeDeactivate = setStatus.mock.calls.length; - handler.deactivate(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeDeactivate); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("skips queued runs that have not started yet after deactivation", async () => { @@ -420,7 +363,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await vi.waitFor(() => { expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); @@ -460,7 +403,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processedMessageIds.push(ctx.messageId ?? "unknown"); }); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); const sequentialDispatch = (async () => { await handler(createMessageData("m-1") as never, {} as never); @@ -499,7 +442,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); diff --git a/src/discord/monitor/message-handler.test-helpers.ts b/src/discord/monitor/message-handler.test-helpers.ts new file mode 100644 index 00000000000..6084fc1a00e --- /dev/null +++ b/src/discord/monitor/message-handler.test-helpers.ts @@ -0,0 +1,76 @@ +import { vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.js"; +import type { createDiscordMessageHandler } from "./message-handler.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +export const DEFAULT_DISCORD_BOT_USER_ID = "bot-123"; + +export function createDiscordHandlerParams(overrides?: { + botUserId?: string; + setStatus?: (patch: Record) => void; + abortSignal?: AbortSignal; + workerRunTimeoutMs?: number; +}): Parameters[0] { + const cfg: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: "test-token", + groupPolicy: "allowlist", + }, + }, + messages: { + inbound: { + debounceMs: 0, + }, + }, + }; + return { + cfg, + discordConfig: cfg.channels?.discord, + accountId: "default", + token: "test-token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: overrides?.botUserId ?? DEFAULT_DISCORD_BOT_USER_ID, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2_000, + replyToMode: "off" as const, + dmEnabled: true, + groupDmEnabled: false, + threadBindings: createNoopThreadBindingManager("default"), + setStatus: overrides?.setStatus, + abortSignal: overrides?.abortSignal, + workerRunTimeoutMs: overrides?.workerRunTimeoutMs, + }; +} + +export function createDiscordPreflightContext(channelId = "ch-1") { + return { + data: { + channel_id: channelId, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + }, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + route: { + sessionKey: `agent:main:discord:channel:${channelId}`, + }, + baseSessionKey: `agent:main:discord:channel:${channelId}`, + messageChannelId: channelId, + }; +} diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index 1e98f349e63..291c6d45bba 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -87,6 +87,75 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } +function createStatusCommand(cfg: OpenClawConfig) { + const commandSpec: NativeCommandSpec = { + name: "status", + description: "Status", + acceptsArgs: false, + }; + return createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); +} + +function setConfiguredBinding(channelId: string, boundSessionKey: string) { + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ + spec: { + channel: "discord", + accountId: "default", + conversationId: channelId, + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: `config:acp:discord:default:${channelId}`, + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: channelId, + }, + status: "active", + boundAt: 0, + }, + }); + persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + ok: true, + sessionKey: boundSessionKey, + }); +} + +function createDispatchSpy() { + return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); +} + +function expectBoundSessionDispatch( + dispatchSpy: ReturnType, + boundSessionKey: string, +) { + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { + ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; + }; + expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); + expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); +} + describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -169,20 +238,7 @@ describe("Discord native plugin command dispatch", () => { }, ], } as OpenClawConfig; - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; - const command = createDiscordNativeCommand({ - command: commandSpec, - cfg, - discordConfig: cfg.channels?.discord ?? {}, - accountId: "default", - sessionPrefix: "discord:slash", - ephemeralDefault: true, - threadBindings: createNoopThreadBindingManager("default"), - }); + const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId, @@ -190,53 +246,14 @@ describe("Discord native plugin command dispatch", () => { guildName: "Ops", }); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "discord", - accountId: "default", - conversationId: channelId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:discord:default:1478836151241412759", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: "default", - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + setConfiguredBinding(channelId, boundSessionKey); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); + const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { - ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; - }; - expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); - expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); it("routes Discord DM native slash commands through configured ACP bindings", async () => { @@ -266,71 +283,19 @@ describe("Discord native plugin command dispatch", () => { }, }, } as OpenClawConfig; - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; - const command = createDiscordNativeCommand({ - command: commandSpec, - cfg, - discordConfig: cfg.channels?.discord ?? {}, - accountId: "default", - sessionPrefix: "discord:slash", - ephemeralDefault: true, - threadBindings: createNoopThreadBindingManager("default"), - }); + const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.DM, channelId, }); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "discord", - accountId: "default", - conversationId: channelId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:discord:default:dm-1", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: "default", - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + setConfiguredBinding(channelId, boundSessionKey); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); + const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { - ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; - }; - expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); - expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); }); diff --git a/src/gateway/input-allowlist.ts b/src/gateway/input-allowlist.ts new file mode 100644 index 00000000000..d59b3e6265c --- /dev/null +++ b/src/gateway/input-allowlist.ts @@ -0,0 +1,9 @@ +export function normalizeInputHostnameAllowlist( + values: string[] | undefined, +): string[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); + return normalized.length > 0 ? normalized : undefined; +} diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 01564f17b34..c4ffb02b148 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -28,6 +28,7 @@ import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { resolveGatewayRequestContext } from "./http-utils.js"; +import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; @@ -70,14 +71,6 @@ type ResolvedOpenAiChatCompletionsLimits = { images: InputImageLimits; }; -function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined { - if (!values || values.length === 0) { - return undefined; - } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); - return normalized.length > 0 ? normalized : undefined; -} - function resolveOpenAiChatCompletionsLimits( config: GatewayHttpChatCompletionsConfig | undefined, ): ResolvedOpenAiChatCompletionsLimits { @@ -94,7 +87,7 @@ function resolveOpenAiChatCompletionsLimits( : DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES, images: { allowUrl: imageConfig?.allowUrl ?? DEFAULT_OPENAI_IMAGE_LIMITS.allowUrl, - urlAllowlist: normalizeHostnameAllowlist(imageConfig?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(imageConfig?.urlAllowlist), allowedMimes: normalizeMimeList(imageConfig?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: imageConfig?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: imageConfig?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 783772016ed..97a5fee3c66 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -35,6 +35,7 @@ import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { resolveGatewayRequestContext } from "./http-utils.js"; +import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; import { CreateResponseBodySchema, type CreateResponseBody, @@ -69,14 +70,6 @@ type ResolvedResponsesLimits = { images: InputImageLimits; }; -function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined { - if (!values || values.length === 0) { - return undefined; - } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); - return normalized.length > 0 ? normalized : undefined; -} - function resolveResponsesLimits( config: GatewayHttpResponsesConfig | undefined, ): ResolvedResponsesLimits { @@ -91,11 +84,11 @@ function resolveResponsesLimits( : DEFAULT_MAX_URL_PARTS, files: { ...fileLimits, - urlAllowlist: normalizeHostnameAllowlist(files?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(files?.urlAllowlist), }, images: { allowUrl: images?.allowUrl ?? true, - urlAllowlist: normalizeHostnameAllowlist(images?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(images?.urlAllowlist), allowedMimes: normalizeMimeList(images?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: images?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: images?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 4e6410c4b36..04144224cd6 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -154,7 +154,12 @@ describe("fetchWithSsrFGuard hardening", () => { "Proxy-Authorization": "Basic c2VjcmV0", Cookie: "session=abc", Cookie2: "legacy=1", + "X-Api-Key": "custom-secret", + "Private-Token": "private-secret", "X-Trace": "1", + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "OpenClaw-Test/1.0", }, }, }); @@ -164,7 +169,12 @@ describe("fetchWithSsrFGuard hardening", () => { expect(headers.get("proxy-authorization")).toBeNull(); expect(headers.get("cookie")).toBeNull(); expect(headers.get("cookie2")).toBeNull(); - expect(headers.get("x-trace")).toBe("1"); + expect(headers.get("x-api-key")).toBeNull(); + expect(headers.get("private-token")).toBeNull(); + expect(headers.get("x-trace")).toBeNull(); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("user-agent")).toBe("OpenClaw-Test/1.0"); await result.release(); }); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index ded0c5fae21..faf81754209 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -52,12 +52,21 @@ type GuardedFetchPresetOptions = Omit< >; const DEFAULT_MAX_REDIRECTS = 3; -const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [ - "authorization", - "proxy-authorization", - "cookie", - "cookie2", -]; +const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([ + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "content-language", + "content-type", + "if-match", + "if-modified-since", + "if-none-match", + "if-unmodified-since", + "pragma", + "range", + "user-agent", +]); export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions { return { ...params, mode: GUARDED_FETCH_MODE.STRICT }; @@ -87,9 +96,12 @@ function stripSensitiveHeadersForCrossOriginRedirect(init?: RequestInit): Reques if (!init?.headers) { return init; } - const headers = new Headers(init.headers); - for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) { - headers.delete(header); + const incoming = new Headers(init.headers); + const headers = new Headers(); + for (const [key, value] of incoming.entries()) { + if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(key.toLowerCase())) { + headers.set(key, value); + } } return { ...init, headers }; } diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index bae5ae5a7d9..f30363205a9 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; import { resolveProviderAuths } from "./provider-usage.auth.js"; describe("resolveProviderAuths key normalization", () => { @@ -403,4 +404,76 @@ describe("resolveProviderAuths key normalization", () => { expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]); }, {}); }); + + it("ignores marker-backed config keys for provider usage auth resolution", async () => { + await withSuiteHome( + async (home) => { + const modelDef = { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + await writeConfig(home, { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [modelDef], + apiKey: NON_ENV_SECRETREF_MARKER, + }, + }, + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["minimax"], + }); + expect(auths).toEqual([]); + }, + { + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + }, + ); + }); + + it("keeps all-caps plaintext config keys eligible for provider usage auth resolution", async () => { + await withSuiteHome( + async (home) => { + const modelDef = { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + await writeConfig(home, { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [modelDef], + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + }, + }, + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["minimax"], + }); + expect(auths).toEqual([{ provider: "minimax", token: "ALLCAPS_SAMPLE" }]); + }, + { + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + }, + ); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index ff63c1570f1..6afa4bebaad 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -8,6 +8,7 @@ import { resolveApiKeyForProfile, resolveAuthProfileOrder, } from "../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { getCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; @@ -103,7 +104,7 @@ function resolveProviderApiKeyFromConfigAndStore(params: { const cfg = loadConfig(); const key = getCustomProviderApiKey(cfg, params.providerId); - if (key) { + if (key && !isNonSecretApiKeyMarker(key)) { return key; } @@ -122,9 +123,17 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return undefined; } if (cred.type === "api_key") { - return normalizeSecretInput(cred.key); + const key = normalizeSecretInput(cred.key); + if (key && !isNonSecretApiKeyMarker(key)) { + return key; + } + return undefined; } - return normalizeSecretInput(cred.token); + const token = normalizeSecretInput(cred.token); + if (token && !isNonSecretApiKeyMarker(token)) { + return token; + } + return undefined; } async function resolveOAuthToken(params: { diff --git a/src/line/bot-handlers.test.ts b/src/line/bot-handlers.test.ts index 39bfdf939e0..a6890a2d1d6 100644 --- a/src/line/bot-handlers.test.ts +++ b/src/line/bot-handlers.test.ts @@ -65,9 +65,50 @@ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({ let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents; let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache; +type LineWebhookContext = Parameters[1]; const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }); +function createReplayMessageEvent(params: { + messageId: string; + groupId: string; + userId: string; + webhookEventId: string; + isRedelivery: boolean; +}) { + return { + type: "message", + message: { id: params.messageId, type: "text", text: "hello" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: params.groupId, userId: params.userId }, + mode: "active", + webhookEventId: params.webhookEventId, + deliveryContext: { isRedelivery: params.isRedelivery }, + } as MessageEvent; +} + +function createOpenGroupReplayContext( + processMessage: LineWebhookContext["processMessage"], + replayCache: ReturnType, +): Parameters[1] { + return { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { groupPolicy: "open" }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + replayCache, + }; +} + vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: readAllowFromStoreMock, upsertChannelPairingRequest: upsertPairingRequestMock, @@ -377,32 +418,14 @@ describe("handleLineWebhookEvents", () => { it("deduplicates replayed webhook events by webhookEventId before processing", async () => { const processMessage = vi.fn(); - const event = { - type: "message", - message: { id: "m-replay", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-replay", userId: "user-replay" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-replay", + groupId: "group-replay", + userId: "user-replay", webhookEventId: "evt-replay-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); await handleLineWebhookEvents([event], context); await handleLineWebhookEvents([event], context); @@ -419,32 +442,14 @@ describe("handleLineWebhookEvents", () => { const processMessage = vi.fn(async () => { await firstDone; }); - const event = { - type: "message", - message: { id: "m-inflight", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-inflight", userId: "user-inflight" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-inflight", + groupId: "group-inflight", + userId: "user-inflight", webhookEventId: "evt-inflight-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); const firstRun = handleLineWebhookEvents([event], context); await Promise.resolve(); @@ -464,32 +469,14 @@ describe("handleLineWebhookEvents", () => { const processMessage = vi.fn(async () => { await firstDone; }); - const event = { - type: "message", - message: { id: "m-inflight-fail", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-inflight", userId: "user-inflight" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-inflight-fail", + groupId: "group-inflight", + userId: "user-inflight", webhookEventId: "evt-inflight-fail-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); const firstRun = handleLineWebhookEvents([event], context); await Promise.resolve(); @@ -604,32 +591,14 @@ describe("handleLineWebhookEvents", () => { .fn() .mockRejectedValueOnce(new Error("transient failure")) .mockResolvedValueOnce(undefined); - const event = { - type: "message", - message: { id: "m-fail-then-retry", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-retry", userId: "user-retry" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-fail-then-retry", + groupId: "group-retry", + userId: "user-retry", webhookEventId: "evt-fail-then-retry", - deliveryContext: { isRedelivery: false }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: false, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure"); await handleLineWebhookEvents([event], context); diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index f28d41e66cf..06ed5b0d09b 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -28,6 +28,7 @@ import { isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore, + type NormalizedAllowFrom, } from "./bot-access.js"; import { getLineSourceInfo, @@ -350,17 +351,15 @@ async function shouldProcessLineEvent( return denied; } } - const allowForCommands = effectiveGroupAllow; - const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - }); - return { allowed: true, commandAuthorized: commandGate.commandAuthorized }; + return { + allowed: true, + commandAuthorized: resolveLineCommandAuthorized({ + cfg, + event, + senderId, + allow: effectiveGroupAllow, + }), + }; } if (dmPolicy === "disabled") { @@ -386,17 +385,15 @@ async function shouldProcessLineEvent( return denied; } - const allowForCommands = effectiveDmAllow; - const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - }); - return { allowed: true, commandAuthorized: commandGate.commandAuthorized }; + return { + allowed: true, + commandAuthorized: resolveLineCommandAuthorized({ + cfg, + event, + senderId, + allow: effectiveDmAllow, + }), + }; } function resolveEventRawText(event: MessageEvent | PostbackEvent): string { @@ -413,6 +410,27 @@ function resolveEventRawText(event: MessageEvent | PostbackEvent): string { return ""; } +function resolveLineCommandAuthorized(params: { + cfg: OpenClawConfig; + event: MessageEvent | PostbackEvent; + senderId?: string; + allow: NormalizedAllowFrom; +}): boolean { + const senderAllowedForCommands = isSenderAllowed({ + allow: params.allow, + senderId: params.senderId, + }); + const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const rawText = resolveEventRawText(params.event); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }], + allowTextCommands: true, + hasControlCommand: hasControlCommand(rawText, params.cfg), + }); + return commandGate.commandAuthorized; +} + async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise { const { cfg, account, runtime, mediaMaxBytes, processMessage } = context; const message = event.message; diff --git a/src/markdown/fences.ts b/src/markdown/fences.ts index d3cbbced1c6..282b6ecc296 100644 --- a/src/markdown/fences.ts +++ b/src/markdown/fences.ts @@ -73,7 +73,27 @@ export function parseFenceSpans(buffer: string): FenceSpan[] { } export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined { - return spans.find((span) => index > span.start && index < span.end); + let low = 0; + let high = spans.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const span = spans[mid]; + if (!span) { + break; + } + if (index <= span.start) { + high = mid - 1; + continue; + } + if (index >= span.end) { + low = mid + 1; + continue; + } + return span; + } + + return undefined; } export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean { diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 38df19b7432..253c8d6eefa 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -29,7 +29,10 @@ describe("runCapability deepgram provider options", () => { deepgram: { baseUrl: "https://provider.example", apiKey: "test-key", - headers: { "X-Provider": "1" }, + headers: { + "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", + }, models: [], }, }, @@ -39,7 +42,10 @@ describe("runCapability deepgram provider options", () => { audio: { enabled: true, baseUrl: "https://config.example", - headers: { "X-Config": "2" }, + headers: { + "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", + }, providerOptions: { deepgram: { detect_language: true, @@ -52,7 +58,10 @@ describe("runCapability deepgram provider options", () => { provider: "deepgram", model: "nova-3", baseUrl: "https://entry.example", - headers: { "X-Entry": "3" }, + headers: { + "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", + }, providerOptions: { deepgram: { detectLanguage: false, @@ -79,8 +88,11 @@ describe("runCapability deepgram provider options", () => { expect(seenBaseUrl).toBe("https://entry.example"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", }); expect(seenQuery).toMatchObject({ detect_language: false, diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 8423ece464d..cdd9468c4a7 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -40,6 +40,26 @@ import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; export type ProviderRegistry = Map; +function sanitizeProviderHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value !== "string") { + continue; + } + // Intentionally preserve marker-shaped values here. This path handles + // explicit config/runtime provider headers, where literal values may + // legitimately match marker patterns; discovered models.json entries are + // sanitized separately in the model registry path. + next[key] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +} + function trimOutput(text: string, maxChars?: number): string { const trimmed = text.trim(); if (!maxChars || trimmed.length <= maxChars) { @@ -352,9 +372,9 @@ async function resolveProviderExecutionContext(params: { }); const baseUrl = params.entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl; const mergedHeaders = { - ...providerConfig?.headers, - ...params.config?.headers, - ...params.entry.headers, + ...sanitizeProviderHeaders(providerConfig?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.config?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.entry.headers as Record | undefined), }; const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined; return { apiKeys, baseUrl, headers }; diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 8489d4cb892..bb67d56878e 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -61,6 +61,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; diff --git a/src/plugin-sdk/channel-plugin-common.ts b/src/plugin-sdk/channel-plugin-common.ts new file mode 100644 index 00000000000..59c347c8f0c --- /dev/null +++ b/src/plugin-sdk/channel-plugin-common.ts @@ -0,0 +1,21 @@ +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; + +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; + +export { getChatChannelMeta } from "../channels/registry.js"; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f9c4b6051df..d0408c604bf 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,28 +1,8 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; export type { ResolvedDiscordAccount } from "../discord/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listDiscordAccountIds, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 300daefc983..360623d9e9c 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -43,6 +43,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { installRequestBodyLimitGuard } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 44dfbd4a149..dd181fee26c 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,25 +1,5 @@ -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ResolvedIMessageAccount } from "../imessage/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 63712fc8d71..b029062e28a 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -73,6 +73,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 12c18efea78..9ad22e60284 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -63,6 +63,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { BlockStreamingCoalesceSchema, DmPolicySchema, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 14d633a4c85..03116a7864b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -51,6 +51,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { BlockStreamingCoalesceSchema, diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts new file mode 100644 index 00000000000..d5eb3a0767e --- /dev/null +++ b/src/plugin-sdk/secret-input-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export function buildSecretInputSchema() { + return z.union([ + z.string(), + z.object({ + source: z.enum(["env", "file", "exec"]), + provider: z.string().min(1), + id: z.string().min(1), + }), + ]); +} diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index d15d35ee1dc..32f291913a5 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,26 +1,6 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ResolvedSignalAccount } from "../signal/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index b0df1329bb9..debb4b75fea 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,27 +1,7 @@ -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { InspectedSlackAccount } from "../slack/account-inspect.js"; export type { ResolvedSlackAccount } from "../slack/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listSlackAccountIds, resolveDefaultSlackAccountId, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 440cffd0de9..852c6f17f0c 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -48,6 +48,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index a8e5ecd0cf8..7f097ef5d43 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -149,6 +149,18 @@ function createOpenAiProviderTarget(params?: { }; } +function createOpenAiProviderHeaderTarget(params?: { + path?: string; + pathSegments?: string[]; +}): SecretsApplyPlan["targets"][number] { + return { + type: "models.providers.headers", + path: params?.path ?? "models.providers.openai.headers.x-api-key", + ...(params?.pathSegments ? { pathSegments: params.pathSegments } : {}), + ref: OPENAI_API_KEY_ENV_REF, + }; +} + function createOneWayScrubOptions(): NonNullable { return { scrubEnv: true, @@ -436,6 +448,47 @@ describe("secrets apply", () => { }); }); + it("applies model provider header targets", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + ...createOpenAiProviderConfig(), + headers: { + "x-api-key": "sk-header-plaintext", + }, + }, + }, + }, + }); + + const plan = createPlan({ + targets: [ + createOpenAiProviderHeaderTarget({ + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + }), + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + const nextConfig = await applyPlanAndReadConfig<{ + models?: { + providers?: { + openai?: { + headers?: Record; + }; + }; + }; + }>(fixture, plan); + expect(nextConfig.models?.providers?.openai?.headers?.["x-api-key"]).toEqual( + OPENAI_API_KEY_ENV_REF, + ); + }); + it("applies array-indexed targets for agent memory search", async () => { await fs.writeFile( fixture.configPath, diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index cd85d84d3d8..b797494d54a 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -10,10 +10,13 @@ type AuditFixture = { configPath: string; authStorePath: string; authJsonPath: string; + modelsPath: string; envPath: string; env: NodeJS.ProcessEnv; }; +const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret + async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } @@ -27,9 +30,11 @@ function resolveRuntimePathEnv(): string { function hasFinding( report: Awaited>, - predicate: (entry: { code: string; file: string }) => boolean, + predicate: (entry: { code: string; file: string; jsonPath?: string }) => boolean, ): boolean { - return report.findings.some((entry) => predicate(entry as { code: string; file: string })); + return report.findings.some((entry) => + predicate(entry as { code: string; file: string; jsonPath?: string }), + ); } async function createAuditFixture(): Promise { @@ -38,6 +43,7 @@ async function createAuditFixture(): Promise { const configPath = path.join(stateDir, "openclaw.json"); const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); const authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json"); + const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json"); const envPath = path.join(stateDir, ".env"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -49,6 +55,7 @@ async function createAuditFixture(): Promise { configPath, authStorePath, authJsonPath, + modelsPath, envPath, env: { OPENCLAW_STATE_DIR: stateDir, @@ -64,7 +71,7 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { openai: { baseUrl: "https://api.openai.com/v1", api: "openai-completions", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, models: [{ id: "gpt-5", name: "gpt-5" }], }, }; @@ -85,7 +92,21 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { version: 1, profiles: Object.fromEntries(seededProfiles), }); - await fs.writeFile(fixture.envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8"); + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + await fs.writeFile( + fixture.envPath, + `${OPENAI_API_KEY_MARKER}=sk-openai-plaintext\n`, // pragma: allowlist secret + "utf8", + ); } describe("secrets audit", () => { @@ -254,4 +275,244 @@ describe("secrets audit", () => { const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; expect(callCount).toBe(1); }); + + it("scans agent models.json files for plaintext provider apiKey values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "sk-models-plaintext", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + expect(report.filesScanned).toContain(fixture.modelsPath); + }); + + it("scans agent models.json files for plaintext provider header values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: "Bearer sk-header-plaintext", // pragma: allowlist secret + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + + it("does not flag non-sensitive routing headers in models.json", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + "X-Proxy-Region": "us-west", + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); + }); + + it("does not flag models.json marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(false); + }); + + it("flags arbitrary all-caps models.json apiKey values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + }); + + it("does not flag models.json header marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret + "x-managed-token": "secretref-managed", // pragma: allowlist secret + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(false); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.x-managed-token", + ), + ).toBe(false); + }); + + it("reports unresolved models.json SecretRef objects in provider headers", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret + }, + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "REF_UNRESOLVED" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + + it("reports malformed models.json as unresolved findings", async () => { + await fs.writeFile(fixture.modelsPath, "{bad-json", "utf8"); + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath, + ), + ).toBe(true); + }); + + it("does not flag non-sensitive routing headers in openclaw config", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, + headers: { + "X-Proxy-Region": "us-west", + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: {}, + }); + await fs.writeFile(fixture.envPath, "", "utf8"); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.configPath && + entry.jsonPath === "models.providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); + }); }); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 132ea4ac431..3215b3ce855 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -1,8 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + isNonSecretApiKeyMarker, + isSecretRefHeaderValueMarker, +} from "../agents/model-auth-markers.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { resolveStateDir, type OpenClawConfig } from "../config/config.js"; +import { coerceSecretRef } from "../config/types.secrets.js"; import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; @@ -23,6 +28,7 @@ import { import { isNonEmptyString, isRecord } from "./shared.js"; import { describeUnknownError } from "./shared.js"; import { + listAgentModelsJsonPaths, listAuthProfileStorePaths, listLegacyAuthJsonPaths, parseEnvAssignmentValue, @@ -91,6 +97,40 @@ type AuditCollector = { }; const REF_RESOLVE_FALLBACK_CONCURRENCY = 8; +const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ + "authorization", + "proxy-authorization", + "x-api-key", + "api-key", + "apikey", + "x-auth-token", + "auth-token", + "x-access-token", + "access-token", + "x-secret-key", + "secret-key", +]); +const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [ + "api-key", + "apikey", + "token", + "secret", + "password", + "credential", +]; + +function isLikelySensitiveModelProviderHeaderName(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES.has(normalized)) { + return true; + } + return SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS.some((fragment) => + normalized.includes(fragment), + ); +} function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void { collector.findings.push(finding); @@ -192,6 +232,12 @@ function collectConfigSecrets(params: { target.value, target.entry.expectedResolvedValue, ); + if ( + target.entry.id === "models.providers.*.headers.*" && + !isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "") + ) { + continue; + } if (!hasPlaintext) { continue; } @@ -315,6 +361,93 @@ function collectAuthJsonResidue(params: { stateDir: string; collector: AuditColl } } +function collectModelsJsonSecrets(params: { + modelsJsonPath: string; + collector: AuditCollector; +}): void { + if (!fs.existsSync(params.modelsJsonPath)) { + return; + } + params.collector.filesScanned.add(params.modelsJsonPath); + const parsedResult = readJsonObjectIfExists(params.modelsJsonPath); + if (parsedResult.error) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: "", + message: `Invalid JSON in models.json: ${parsedResult.error}`, + }); + return; + } + const parsed = parsedResult.value; + if (!parsed || !isRecord(parsed.providers)) { + return; + } + for (const [providerId, providerValue] of Object.entries(parsed.providers)) { + if (!isRecord(providerValue)) { + continue; + } + const apiKey = providerValue.apiKey; + if (coerceSecretRef(apiKey)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json contains an unresolved SecretRef object; regenerate models.json.", + provider: providerId, + }); + } else if (isNonEmptyString(apiKey) && !isNonSecretApiKeyMarker(apiKey)) { + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json provider apiKey is stored as plaintext.", + provider: providerId, + }); + } + + const headers = isRecord(providerValue.headers) ? providerValue.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + const headerPath = `providers.${providerId}.headers.${headerKey}`; + if (coerceSecretRef(headerValue)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: + "models.json contains an unresolved SecretRef object for provider headers; regenerate models.json.", + provider: providerId, + }); + continue; + } + if (!isNonEmptyString(headerValue)) { + continue; + } + if (isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + if (!isLikelySensitiveModelProviderHeaderName(headerKey)) { + continue; + } + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: "models.json provider header value is stored as plaintext.", + provider: providerId, + }); + } + } +} + async function collectUnresolvedRefFindings(params: { collector: AuditCollector; config: OpenClawConfig; @@ -497,6 +630,12 @@ export async function runSecretsAudit( defaults, }); } + for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir)) { + collectModelsJsonSecrets({ + modelsJsonPath, + collector, + }); + } await collectUnresolvedRefFindings({ collector, config, diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 95071d549e1..01ee81ea551 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -21,6 +21,22 @@ describe("secrets plan validation", () => { expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]); }); + it("accepts model provider header targets with wildcard-backed paths", () => { + const resolved = resolveValidatedPlanTarget({ + type: "models.providers.headers", + path: "models.providers.openai.headers.x-api-key", + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + providerId: "openai", + }); + expect(resolved?.pathSegments).toEqual([ + "models", + "providers", + "openai", + "headers", + "x-api-key", + ]); + }); + it("rejects target paths that do not match the registered shape", () => { const resolved = resolveValidatedPlanTarget({ type: "channels.telegram.botToken", diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 085573173cc..504331f0a96 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -10,6 +10,7 @@ import { isRecord } from "./shared.js"; type ProviderLike = { apiKey?: unknown; + headers?: unknown; enabled?: unknown; }; @@ -24,18 +25,37 @@ function collectModelProviderAssignments(params: { context: ResolverContext; }): void { for (const [providerId, provider] of Object.entries(params.providers)) { + const providerIsActive = provider.enabled !== false; collectSecretInputAssignment({ value: provider.apiKey, path: `models.providers.${providerId}.apiKey`, expected: "string", defaults: params.defaults, context: params.context, - active: provider.enabled !== false, + active: providerIsActive, inactiveReason: "provider is disabled.", apply: (value) => { provider.apiKey = value; }, }); + const headers = isRecord(provider.headers) ? provider.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + collectSecretInputAssignment({ + value: headerValue, + path: `models.providers.${providerId}.headers.${headerKey}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: providerIsActive, + inactiveReason: "provider is disabled.", + apply: (value) => { + headers[headerKey] = value; + }, + }); + } } } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index e1ca5774a75..1d9189f843c 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -56,6 +56,13 @@ describe("secrets runtime snapshot", () => { openai: { baseUrl: "https://api.openai.com/v1", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_PROVIDER_AUTH_HEADER", + }, + }, models: [], }, }, @@ -123,6 +130,7 @@ describe("secrets runtime snapshot", () => { config, env: { OPENAI_API_KEY: "sk-env-openai", // pragma: allowlist secret + OPENAI_PROVIDER_AUTH_HEADER: "Bearer sk-env-header", // pragma: allowlist secret GITHUB_TOKEN: "ghp-env-token", // pragma: allowlist secret REVIEW_SKILL_API_KEY: "sk-skill-ref", // pragma: allowlist secret MEMORY_REMOTE_API_KEY: "mem-ref-key", // pragma: allowlist secret @@ -162,6 +170,9 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.config.models?.providers?.openai?.headers?.Authorization).toBe( + "Bearer sk-env-header", + ); expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); expect(snapshot.config.talk?.apiKey).toBe("talk-ref-key"); diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index ccbfc544f6d..557f611c006 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; import { listAuthProfileStorePaths as listAuthProfileStorePathsFromAuthStorePaths } from "./auth-store-paths.js"; @@ -31,6 +32,32 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] { return out; } +export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: string): string[] { + const paths = new Set(); + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + if (fs.existsSync(agentsRoot)) { + for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + paths.add(path.join(agentsRoot, entry.name, "agent", "models.json")); + } + } + + for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + continue; + } + const agentDir = resolveAgentDir(config, agentId); + paths.add(path.join(resolveUserPath(agentDir), "models.json")); + } + + return [...paths]; +} + export function readJsonObjectIfExists(filePath: string): { value: Record | null; error?: string; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 61ccb1f9b66..3be4992d28f 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -642,6 +642,19 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ providerIdPathSegmentIndex: 2, trackProviderShadowing: true, }, + { + id: "models.providers.*.headers.*", + targetType: "models.providers.headers", + targetTypeAliases: ["models.providers.*.headers.*"], + configFile: "openclaw.json", + pathPattern: "models.providers.*.headers.*", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + providerIdPathSegmentIndex: 2, + }, { id: "skills.entries.*.apiKey", targetType: "skills.entries.apiKey", diff --git a/src/secrets/target-registry-pattern.test.ts b/src/secrets/target-registry-pattern.test.ts index 4739ca5776d..2cd3537fb53 100644 --- a/src/secrets/target-registry-pattern.test.ts +++ b/src/secrets/target-registry-pattern.test.ts @@ -39,6 +39,17 @@ describe("target registry pattern helpers", () => { expect(materializePathTokens(refTokens, ["anthropic"])).toBeNull(); }); + it("matches two wildcard captures in five-segment header paths", () => { + const tokens = parsePathPattern("models.providers.*.headers.*"); + const match = matchPathTokens( + ["models", "providers", "openai", "headers", "x-api-key"], + tokens, + ); + expect(match).toEqual({ + captures: ["openai", "x-api-key"], + }); + }); + it("expands wildcard and array patterns over config objects", () => { const root = { agents: { diff --git a/src/slack/account-inspect.ts b/src/slack/account-inspect.ts index f29d718aa28..34b4a13fb23 100644 --- a/src/slack/account-inspect.ts +++ b/src/slack/account-inspect.ts @@ -1,9 +1,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; import type { SlackAccountConfig } from "../config/types.slack.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultSlackAccountId, type SlackTokenSource } from "./accounts.js"; +import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; +import { + mergeSlackAccountConfig, + resolveDefaultSlackAccountId, + type SlackTokenSource, +} from "./accounts.js"; export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing"; @@ -26,33 +30,7 @@ export type InspectedSlackAccount = { userTokenStatus: SlackCredentialStatus; configured: boolean; config: SlackAccountConfig; - groupPolicy?: SlackAccountConfig["groupPolicy"]; - textChunkLimit?: SlackAccountConfig["textChunkLimit"]; - mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; - reactionNotifications?: SlackAccountConfig["reactionNotifications"]; - reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; - replyToMode?: SlackAccountConfig["replyToMode"]; - replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; - actions?: SlackAccountConfig["actions"]; - slashCommand?: SlackAccountConfig["slashCommand"]; - dm?: SlackAccountConfig["dm"]; - channels?: SlackAccountConfig["channels"]; -}; - -function resolveSlackAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): SlackAccountConfig | undefined { - return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId); -} - -function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { - accounts?: unknown; - }; - const account = resolveSlackAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +} & SlackAccountSurfaceFields; function inspectSlackToken(value: unknown): { token?: string; diff --git a/src/slack/account-surface-fields.ts b/src/slack/account-surface-fields.ts new file mode 100644 index 00000000000..8e2293e213a --- /dev/null +++ b/src/slack/account-surface-fields.ts @@ -0,0 +1,15 @@ +import type { SlackAccountConfig } from "../config/types.js"; + +export type SlackAccountSurfaceFields = { + groupPolicy?: SlackAccountConfig["groupPolicy"]; + textChunkLimit?: SlackAccountConfig["textChunkLimit"]; + mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; + reactionNotifications?: SlackAccountConfig["reactionNotifications"]; + reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; + replyToMode?: SlackAccountConfig["replyToMode"]; + replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; + actions?: SlackAccountConfig["actions"]; + slashCommand?: SlackAccountConfig["slashCommand"]; + dm?: SlackAccountConfig["dm"]; + channels?: SlackAccountConfig["channels"]; +}; diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index b997a2cccd7..6e5aed59fa2 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; export type SlackTokenSource = "env" | "config" | "none"; @@ -19,18 +20,7 @@ export type ResolvedSlackAccount = { appTokenSource: SlackTokenSource; userTokenSource: SlackTokenSource; config: SlackAccountConfig; - groupPolicy?: SlackAccountConfig["groupPolicy"]; - textChunkLimit?: SlackAccountConfig["textChunkLimit"]; - mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; - reactionNotifications?: SlackAccountConfig["reactionNotifications"]; - reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; - replyToMode?: SlackAccountConfig["replyToMode"]; - replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; - actions?: SlackAccountConfig["actions"]; - slashCommand?: SlackAccountConfig["slashCommand"]; - dm?: SlackAccountConfig["dm"]; - channels?: SlackAccountConfig["channels"]; -}; +} & SlackAccountSurfaceFields; const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack"); export const listSlackAccountIds = listAccountIds; @@ -43,7 +33,10 @@ function resolveAccountConfig( return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId); } -function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig { +export function mergeSlackAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): SlackAccountConfig { const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { accounts?: unknown; }; diff --git a/src/slack/monitor/message-handler.app-mention-race.test.ts b/src/slack/monitor/message-handler.app-mention-race.test.ts index c84b6514b43..8c6afb15a8b 100644 --- a/src/slack/monitor/message-handler.app-mention-race.test.ts +++ b/src/slack/monitor/message-handler.app-mention-race.test.ts @@ -67,6 +67,55 @@ function createMarkMessageSeen() { }; } +function createTestHandler() { + return createSlackMessageHandler({ + ctx: { + cfg: {}, + accountId: "default", + app: { client: {} }, + runtime: {}, + markMessageSeen: createMarkMessageSeen(), + } as Parameters[0]["ctx"], + account: { accountId: "default" } as Parameters[0]["account"], + }); +} + +function createSlackEvent(params: { type: "message" | "app_mention"; ts: string; text: string }) { + return { type: params.type, channel: "C1", ts: params.ts, text: params.text } as never; +} + +async function sendMessageEvent(handler: ReturnType, ts: string) { + await handler(createSlackEvent({ type: "message", ts, text: "hello" }), { source: "message" }); +} + +async function sendMentionEvent(handler: ReturnType, ts: string) { + await handler(createSlackEvent({ type: "app_mention", ts, text: "<@U_BOT> hello" }), { + source: "app_mention", + wasMentioned: true, + }); +} + +async function createInFlightMessageScenario(ts: string) { + let resolveMessagePrepare: ((value: unknown) => void) | undefined; + const messagePrepare = new Promise((resolve) => { + resolveMessagePrepare = resolve; + }); + prepareSlackMessageMock.mockImplementation(async ({ opts }) => { + if (opts.source === "message") { + return messagePrepare; + } + return { ctxPayload: {} }; + }); + + const handler = createTestHandler(); + const messagePending = handler(createSlackEvent({ type: "message", ts, text: "hello" }), { + source: "message", + }); + await Promise.resolve(); + + return { handler, messagePending, resolveMessagePrepare }; +} + describe("createSlackMessageHandler app_mention race handling", () => { beforeEach(() => { prepareSlackMessageMock.mockReset(); @@ -81,144 +130,36 @@ describe("createSlackMessageHandler app_mention race handling", () => { return { ctxPayload: {} }; }); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); + const handler = createTestHandler(); - await handler( - { type: "message", channel: "C1", ts: "1700000000.000100", text: "hello" } as never, - { source: "message" }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000100", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000100", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMessageEvent(handler, "1700000000.000100"); + await sendMentionEvent(handler, "1700000000.000100"); + await sendMentionEvent(handler, "1700000000.000100"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(2); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); }); it("allows app_mention while message handling is still in-flight, then keeps later duplicates deduped", async () => { - let resolveMessagePrepare: ((value: unknown) => void) | undefined; - const messagePrepare = new Promise((resolve) => { - resolveMessagePrepare = resolve; - }); - prepareSlackMessageMock.mockImplementation(async ({ opts }) => { - if (opts.source === "message") { - return messagePrepare; - } - return { ctxPayload: {} }; - }); + const { handler, messagePending, resolveMessagePrepare } = + await createInFlightMessageScenario("1700000000.000150"); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); - - const messagePending = handler( - { type: "message", channel: "C1", ts: "1700000000.000150", text: "hello" } as never, - { source: "message" }, - ); - await Promise.resolve(); - - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000150", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000150"); resolveMessagePrepare?.(null); await messagePending; - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000150", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000150"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(2); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); }); it("suppresses message dispatch when app_mention already dispatched during in-flight race", async () => { - let resolveMessagePrepare: ((value: unknown) => void) | undefined; - const messagePrepare = new Promise((resolve) => { - resolveMessagePrepare = resolve; - }); - prepareSlackMessageMock.mockImplementation(async ({ opts }) => { - if (opts.source === "message") { - return messagePrepare; - } - return { ctxPayload: {} }; - }); + const { handler, messagePending, resolveMessagePrepare } = + await createInFlightMessageScenario("1700000000.000175"); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); - - const messagePending = handler( - { type: "message", channel: "C1", ts: "1700000000.000175", text: "hello" } as never, - { source: "message" }, - ); - await Promise.resolve(); - - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000175", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000175"); resolveMessagePrepare?.({ ctxPayload: {} }); await messagePending; @@ -230,32 +171,10 @@ describe("createSlackMessageHandler app_mention race handling", () => { it("keeps app_mention deduped when message event already dispatched", async () => { prepareSlackMessageMock.mockResolvedValue({ ctxPayload: {} }); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); + const handler = createTestHandler(); - await handler( - { type: "message", channel: "C1", ts: "1700000000.000200", text: "hello" } as never, - { source: "message" }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000200", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMessageEvent(handler, "1700000000.000200"); + await sendMentionEvent(handler, "1700000000.000200"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index a5bdebc1e2d..a5007831a2b 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -7,12 +7,11 @@ import { expectInboundContextContract } from "../../../../test/helpers/inbound-c import type { OpenClawConfig } from "../../../config/config.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; -import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; -import { createSlackMonitorContext } from "../context.js"; import { prepareSlackMessage } from "./prepare.js"; +import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js"; describe("slack prepareSlackMessage inbound contract", () => { let fixtureRoot = ""; @@ -38,53 +37,7 @@ describe("slack prepareSlackMessage inbound contract", () => { } }); - function createInboundSlackCtx(params: { - cfg: OpenClawConfig; - appClient?: App["client"]; - defaultRequireMention?: boolean; - replyToMode?: "off" | "all"; - channelsConfig?: Record; - }) { - return createSlackMonitorContext({ - cfg: params.cfg, - accountId: "default", - botToken: "token", - app: { client: params.appClient ?? {} } as App, - runtime: {} as RuntimeEnv, - botUserId: "B1", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - sessionScope: "per-sender", - mainKey: "main", - dmEnabled: true, - dmPolicy: "open", - allowFrom: [], - allowNameMatching: false, - groupDmEnabled: true, - groupDmChannels: [], - defaultRequireMention: params.defaultRequireMention ?? true, - channelsConfig: params.channelsConfig, - groupPolicy: "open", - useAccessGroups: false, - reactionMode: "off", - reactionAllowlist: [], - replyToMode: params.replyToMode ?? "off", - threadHistoryScope: "thread", - threadInheritParent: false, - slashCommand: { - enabled: false, - name: "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }, - textLimit: 4000, - ackReactionScope: "group-mentions", - typingReaction: "", - mediaMaxBytes: 1024, - removeAckAfterReply: false, - }); - } + const createInboundSlackCtx = createInboundSlackTestContext; function createDefaultSlackCtx() { const slackCtx = createInboundSlackCtx({ @@ -115,19 +68,7 @@ describe("slack prepareSlackMessage inbound contract", () => { }); } - function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { - return { - accountId: "default", - enabled: true, - botTokenSource: "config", - appTokenSource: "config", - userTokenSource: "none", - config, - replyToMode: config.replyToMode, - replyToModeByChatType: config.replyToModeByChatType, - dm: config.dm, - }; - } + const createSlackAccount = createSlackTestAccount; function createSlackMessage(overrides: Partial): SlackMessageEvent { return { diff --git a/src/telegram/account-inspect.ts b/src/telegram/account-inspect.ts index 5c50c7d7d67..305e410d39a 100644 --- a/src/telegram/account-inspect.ts +++ b/src/telegram/account-inspect.ts @@ -3,9 +3,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; import type { TelegramAccountConfig } from "../config/types.telegram.js"; import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultTelegramAccountId } from "./accounts.js"; +import { + mergeTelegramAccountConfig, + resolveDefaultTelegramAccountId, + resolveTelegramAccountConfig, +} from "./accounts.js"; export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing"; @@ -20,31 +23,6 @@ export type InspectedTelegramAccount = { config: TelegramAccountConfig; }; -function resolveTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig | undefined { - const normalized = normalizeAccountId(accountId); - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); -} - -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { - const { - accounts: _ignored, - defaultAccount: _ignoredDefaultAccount, - groups: channelGroups, - ...base - } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { - accounts?: unknown; - defaultAccount?: unknown; - }; - const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; - const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - const isMultiAccount = configuredAccountIds.length > 1; - const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); - return { ...base, ...account, groups }; -} - function inspectTokenFile(pathValue: unknown): { token: string; tokenSource: "tokenFile" | "none"; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index e3d86ec84b4..b8c656d1bfd 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -97,7 +97,7 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { return ids[0] ?? DEFAULT_ACCOUNT_ID; } -function resolveAccountConfig( +export function resolveTelegramAccountConfig( cfg: OpenClawConfig, accountId: string, ): TelegramAccountConfig | undefined { @@ -105,7 +105,10 @@ function resolveAccountConfig( return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); } -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { +export function mergeTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig { const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, @@ -115,7 +118,7 @@ function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): Tel accounts?: unknown; defaultAccount?: unknown; }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; + const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; // In multi-account setups, channel-level `groups` must NOT be inherited by // accounts that don't have their own `groups` config. A bot that is not a @@ -138,7 +141,7 @@ export function createTelegramActionGate(params: { const accountId = normalizeAccountId(params.accountId); return createAccountActionGate({ baseActions: params.cfg.channels?.telegram?.actions, - accountActions: resolveAccountConfig(params.cfg, accountId)?.actions, + accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions, }); } diff --git a/src/telegram/bot-message-context.topic-agentid.test.ts b/src/telegram/bot-message-context.topic-agentid.test.ts index b3b634b4768..d3e24060278 100644 --- a/src/telegram/bot-message-context.topic-agentid.test.ts +++ b/src/telegram/bot-message-context.topic-agentid.test.ts @@ -21,58 +21,51 @@ vi.mock("../config/config.js", async (importOriginal) => { }); describe("buildTelegramMessageContext per-topic agentId routing", () => { + function buildForumMessage(threadId = 3) { + return { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup" as const, + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: threadId, + from: { id: 42, first_name: "Alice" }, + }; + } + + async function buildForumContext(params: { + threadId?: number; + topicConfig?: Record; + }) { + return await buildTelegramMessageContextForTest({ + message: buildForumMessage(params.threadId), + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + ...(params.topicConfig ? { topicConfig: params.topicConfig } : {}), + }), + }); + } + beforeEach(() => { vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never); }); it("uses group-level agent when no topic agentId is set", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { systemPrompt: "Be nice" }, - }), - }); + const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } }); expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3"); }); it("routes to topic-specific agent when agentId is set", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, - }), + const ctx = await buildForumContext({ + topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, }); expect(ctx).not.toBeNull(); @@ -82,27 +75,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { it("different topics route to different agents", async () => { const buildForTopic = async (threadId: number, agentId: string) => - await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: threadId, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId }, - }), - }); + await buildForumContext({ threadId, topicConfig: { agentId } }); const ctxA = await buildForTopic(1, "main"); const ctxB = await buildForTopic(3, "zu"); @@ -117,26 +90,8 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { }); it("ignores whitespace-only agentId and uses group-level agent", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: " ", systemPrompt: "Be nice" }, - }), + const ctx = await buildForumContext({ + topicConfig: { agentId: " ", systemPrompt: "Be nice" }, }); expect(ctx).not.toBeNull(); @@ -152,27 +107,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { messages: { groupChat: { mentionPatterns: [] } }, } as never); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "ghost" }, - }), - }); + const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 9f0a9f4116d..1b05ddd0d9c 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -131,37 +131,22 @@ function registerAndResolveStatusHandler(params: { sendMessage: ReturnType; } { const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; - const commandHandlers = new Map(); - const sendMessage = vi.fn().mockResolvedValue(undefined); - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({ - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage, - }, - command: vi.fn((name: string, cb: TelegramCommandHandler) => { - commandHandlers.set(name, cb); - }), - } as unknown as Parameters[0]["bot"], - cfg, - allowFrom: allowFrom ?? ["*"], - groupAllowFrom: groupAllowFrom ?? [], - resolveTelegramGroupConfig, - }), + return registerAndResolveCommandHandlerBase({ + commandName: "status", + cfg, + allowFrom: allowFrom ?? ["*"], + groupAllowFrom: groupAllowFrom ?? [], + useAccessGroups: true, + resolveTelegramGroupConfig, }); - - const handler = commandHandlers.get("status"); - expect(handler).toBeTruthy(); - return { handler: handler as TelegramCommandHandler, sendMessage }; } -function registerAndResolveCommandHandler(params: { +function registerAndResolveCommandHandlerBase(params: { commandName: string; cfg: OpenClawConfig; - allowFrom?: string[]; - groupAllowFrom?: string[]; - useAccessGroups?: boolean; + allowFrom: string[]; + groupAllowFrom: string[]; + useAccessGroups: boolean; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -189,9 +174,9 @@ function registerAndResolveCommandHandler(params: { }), } as unknown as Parameters[0]["bot"], cfg, - allowFrom: allowFrom ?? [], - groupAllowFrom: groupAllowFrom ?? [], - useAccessGroups: useAccessGroups ?? true, + allowFrom, + groupAllowFrom, + useAccessGroups, resolveTelegramGroupConfig, }), }); @@ -201,6 +186,72 @@ function registerAndResolveCommandHandler(params: { return { handler: handler as TelegramCommandHandler, sendMessage }; } +function registerAndResolveCommandHandler(params: { + commandName: string; + cfg: OpenClawConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + useAccessGroups?: boolean; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveTelegramGroupConfig, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName, + cfg, + allowFrom: allowFrom ?? [], + groupAllowFrom: groupAllowFrom ?? [], + useAccessGroups: useAccessGroups ?? true, + resolveTelegramGroupConfig, + }); +} + +function createConfiguredAcpTopicBinding(boundSessionKey: string) { + return { + spec: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:telegram:default:-1001234567890:topic:42", + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + status: "active", + boundAt: 0, + }, + } satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; +} + +function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + -1001234567890, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); +} + describe("registerTelegramNativeCommands — session metadata", () => { beforeEach(() => { persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear(); @@ -254,29 +305,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, sessionKey: boundSessionKey, @@ -359,29 +390,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: false, sessionKey: boundSessionKey, @@ -405,29 +416,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, sessionKey: boundSessionKey, @@ -442,14 +433,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(buildStatusTopicCommandContext()); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - -1001234567890, - "You are not authorized to use this command.", - expect.objectContaining({ message_thread_id: 42 }), - ); + expectUnauthorizedNewCommandBlocked(sendMessage); }); it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { @@ -464,13 +448,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(buildStatusTopicCommandContext()); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - -1001234567890, - "You are not authorized to use this command.", - expect.objectContaining({ message_thread_id: 42 }), - ); + expectUnauthorizedNewCommandBlocked(sendMessage); }); });