Merge branch 'main' into vincentkoc-code/issue-28140-invalid-config-fail-closed

This commit is contained in:
Vincent Koc
2026-03-07 13:47:00 -05:00
committed by GitHub
310 changed files with 7969 additions and 7115 deletions

View File

@@ -26,3 +26,18 @@ pattern = === "string"
pattern = typeof remote\?\.password === "string"
# Docker apt signing key fingerprint constant; not a secret.
pattern = OPENCLAW_DOCKER_GPG_FINGERPRINT=
# Credential matrix metadata field in docs JSON; not a secret value.
pattern = "secretShape": "(secret_input|sibling_ref)"
# Docs line describing API key rotation knobs; not a credential.
pattern = API key rotation \(provider-specific\): set `\*_API_KEYS`
# Docs line describing remote password precedence; not a credential.
pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.auth\.passw[o]rd` -> `gateway\.remote\.passw[o]rd`
pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.remote\.passw[o]rd` -> `gateway\.auth\.passw[o]rd`
# Test fixture starts a multiline fake private key; detector should ignore the header line.
pattern = const key = `-----BEGIN PRIVATE KEY-----
# Docs examples: literal placeholder API key snippets and shell heredoc helper.
pattern = export CUSTOM_API_K[E]Y="your-key"
pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \|\| cat >> ~/.bashrc <<'EOF'
pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},
pattern = "ap[i]Key": "xxxxx",
pattern = ap[i]Key: "A[I]za\.\.\.",

View File

@@ -303,9 +303,21 @@ jobs:
install-deps: "false"
- name: Setup Python
id: setup-python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
pyproject.toml
.pre-commit-config.yaml
.github/workflows/ci.yml
- name: Restore pre-commit cache
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: |
@@ -449,9 +461,11 @@ jobs:
cache-key-suffix: "node22"
# Sticky disk mount currently retries/fails on every shard and adds ~50s
# before install while still yielding zero pnpm store reuse.
# Try exact-key actions/cache restores instead to recover store reuse
# without the sticky-disk mount penalty.
use-sticky-disk: "false"
use-restore-keys: "false"
use-actions-cache: "false"
use-actions-cache: "true"
- name: Runtime versions
run: |
@@ -470,7 +484,9 @@ jobs:
which node
node -v
pnpm -v
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
# Persist Windows-native postinstall outputs in the pnpm store so restored
# caches can skip repeated rebuild/download work on later shards/runs.
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true
- name: Configure test shard (Windows)
if: matrix.task == 'test'

View File

@@ -49,6 +49,26 @@ repos:
- 'typeof remote\?\.password === "string"'
- --exclude-lines
- "OPENCLAW_DOCKER_GPG_FINGERPRINT="
- --exclude-lines
- '"secretShape": "(secret_input|sibling_ref)"'
- --exclude-lines
- 'API key rotation \(provider-specific\): set `\*_API_KEYS`'
- --exclude-lines
- 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.auth\.password` -> `gateway\.remote\.password`'
- --exclude-lines
- 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.remote\.password` -> `gateway\.auth\.password`'
- --exclude-files
- '^src/gateway/client\.watchdog\.test\.ts$'
- --exclude-lines
- 'export CUSTOM_API_K[E]Y="your-key"'
- --exclude-lines
- 'grep -q ''N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache'' ~/.bashrc \|\| cat >> ~/.bashrc <<''EOF'''
- --exclude-lines
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
- --exclude-lines
- '"ap[i]Key": "xxxxx",'
- --exclude-lines
- 'ap[i]Key: "A[I]za\.\.\.",'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0

File diff suppressed because it is too large Load Diff

View File

@@ -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.
@@ -240,6 +242,10 @@ Docs: https://docs.openclaw.ai
- 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.
- Config/invalid-load fail-closed: stop converting `INVALID_CONFIG` into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
- 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.
- CLI/bootstrap Node version hint maintenance: replace hardcoded nvm `22` instructions in `openclaw.mjs` with `MIN_NODE_MAJOR` interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash.
- Discord/native slash command auth: honor `commands.allowFrom.discord` (and `commands.allowFrom["*"]`) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow.
## 2026.3.2
@@ -597,7 +603,10 @@ Docs: https://docs.openclaw.ai
### Fixes
- Dashboard/macOS auth handling: switch the macOS “Open Dashboard” flow from query-string token injection to URL fragments, stop persisting Control UI gateway tokens in browser localStorage, and scrub legacy stored tokens on load. Thanks @JNX03 for reporting.
- Models/provider config precedence: prefer exact `models.providers.<name>` 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.
- Hooks/auth throttling: reject non-`POST` `/hooks/*` requests before auth-failure accounting so unsupported methods can no longer burn the hook auth lockout budget and block legitimate webhook delivery. Thanks @JNX03 for reporting.
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
- 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.

View File

@@ -55,7 +55,7 @@ class AppUpdateHandlerTest {
try {
tmp.writeText("hello", Charsets.UTF_8)
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret
sha256Hex(tmp),
)
} finally {

View File

@@ -38,7 +38,9 @@ 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")
beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret
endPemMarker = %w[END PRIVATE KEY].join(" ")
if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker)
UI.message("Decoded hex-encoded ASC key content from Keychain.")
return decoded
end

View File

@@ -661,18 +661,20 @@ extension GatewayEndpointStore {
components.path = "/"
}
var queryItems: [URLQueryItem] = []
var fragmentItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
queryItems.append(URLQueryItem(name: "token", value: token))
fragmentItems.append(URLQueryItem(name: "token", value: token))
}
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
queryItems.append(URLQueryItem(name: "password", value: password))
components.queryItems = nil
if fragmentItems.isEmpty {
components.fragment = nil
} else {
var fragment = URLComponents()
fragment.queryItems = fragmentItems
components.fragment = fragment.percentEncodedQuery
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let url = components.url else {
throw NSError(domain: "Dashboard", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to build dashboard URL",

View File

@@ -216,6 +216,20 @@ import Testing
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
}
@Test func dashboardURLUsesFragmentTokenAndOmitsPassword() throws {
let config: GatewayConnection.Config = try (
url: #require(URL(string: "ws://127.0.0.1:18789")),
token: "abc123",
password: "sekret")
let url = try GatewayEndpointStore.dashboardURL(
for: config,
mode: .local,
localBasePath: "/control")
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123")
#expect(url.query == nil)
}
@Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1")
#expect(url?.port == 18789)

View File

@@ -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.

View File

@@ -38,6 +38,7 @@ Notes:
- `models set <model-or-alias>` 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(<value>)` 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`

View File

@@ -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

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -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:

View File

@@ -2503,7 +2503,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
Facts (from code):
- The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`.
- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage.
Fix:

View File

@@ -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.

View File

@@ -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",

View File

@@ -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.*`

View File

@@ -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.
</Step>

View File

@@ -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.*`

View File

@@ -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** (autogenerated, 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)

View File

@@ -55,7 +55,7 @@ Use `openclaw configure --section web` to set up your API key and choose a provi
### Perplexity Search
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
2. Generate an API key in the dashboard
3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment.
@@ -63,7 +63,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
### Brave Search
1. Create a Brave Search API account at <https://brave.com/search/api/>
1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/)
2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key.
3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
@@ -104,7 +104,7 @@ Brave provides paid plans; check the Brave API portal for the current limits and
search: {
enabled: true,
provider: "brave",
apiKey: "BSA...", // optional if BRAVE_API_KEY is set
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
},
},
},

View File

@@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
Optional one-time auth (if needed):
```text
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token>
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
```
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is stored in localStorage; `password` is kept in memory only.
- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage.
- `password` is kept in memory only.
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).

View File

@@ -24,7 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
Security note: the Control UI is an **admin surface** (chat, config, exec approvals).
Do not expose it publicly. The UI stores the token in `localStorage` after first load.
Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab
and strips them from the URL after load.
Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Fast path (recommended)
@@ -36,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Token basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, but the Control UI does not persist gateway tokens in localStorage.
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance.
- **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).

View File

@@ -2391,11 +2391,11 @@ describe("BlueBubbles webhook monitor", () => {
});
const accountA: ResolvedBlueBubblesAccount = {
...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }),
...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret
accountId: "acc-a",
};
const accountB: ResolvedBlueBubblesAccount = {
...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }),
...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret
accountId: "acc-b",
};
const config: OpenClawConfig = {};

View File

@@ -166,7 +166,7 @@ function createMockAccount(
configured: true,
config: {
serverUrl: "http://localhost:1234",
password: "test-password",
password: "test-password", // pragma: allowlist secret
dmPolicy: "open",
groupPolicy: "open",
allowFrom: [],
@@ -240,15 +240,6 @@ function getFirstDispatchCall(): DispatchReplyParams {
}
describe("BlueBubbles webhook monitor", () => {
const WEBHOOK_PATH = "/bluebubbles-webhook";
const BASE_WEBHOOK_MESSAGE_DATA = {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
} as const;
let unregister: () => void;
beforeEach(() => {
@@ -270,144 +261,122 @@ describe("BlueBubbles webhook monitor", () => {
unregister?.();
});
function createWebhookPayload(
dataOverrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
type: "new-message",
data: {
...BASE_WEBHOOK_MESSAGE_DATA,
...dataOverrides,
},
};
}
function createWebhookTargetDeps(core?: PluginRuntime): {
config: OpenClawConfig;
core: PluginRuntime;
runtime: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
} {
const resolvedCore = core ?? createMockRuntime();
setBlueBubblesRuntime(resolvedCore);
return {
config: {},
core: resolvedCore,
runtime: {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
},
};
}
function registerWebhookTarget(
params: {
account?: ResolvedBlueBubblesAccount;
config?: OpenClawConfig;
core?: PluginRuntime;
runtime?: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
path?: string;
statusSink?: Parameters<typeof registerBlueBubblesWebhookTarget>[0]["statusSink"];
trackForCleanup?: boolean;
} = {},
): {
config: OpenClawConfig;
core: PluginRuntime;
runtime: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
stop: () => void;
} {
const deps =
params.config && params.core && params.runtime
? { config: params.config, core: params.core, runtime: params.runtime }
: createWebhookTargetDeps(params.core);
const stop = registerBlueBubblesWebhookTarget({
account: params.account ?? createMockAccount(),
...deps,
path: params.path ?? WEBHOOK_PATH,
statusSink: params.statusSink,
});
if (params.trackForCleanup !== false) {
unregister = stop;
}
return { ...deps, stop };
}
async function sendWebhookRequest(params: {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
}): Promise<{
req: IncomingMessage;
res: ServerResponse & { body: string; statusCode: number };
handled: boolean;
}> {
const req = createMockRequest(
params.method ?? "POST",
params.url ?? WEBHOOK_PATH,
params.body ?? createWebhookPayload(),
params.headers,
);
if (params.remoteAddress) {
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: params.remoteAddress,
};
}
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
return { req, res, handled };
}
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
method: "GET",
body: {},
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(405);
});
it("accepts POST requests with valid JSON payload", async () => {
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload({ date: Date.now() }),
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("rejects requests with invalid JSON", async () => {
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
body: "invalid json {{",
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
});
it("accepts URL-encoded payload wrappers", async () => {
registerWebhookTarget();
const payload = createWebhookPayload({ date: Date.now() });
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const { handled, res } = await sendWebhookRequest({ body: encodedBody });
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -417,12 +386,23 @@ describe("BlueBubbles webhook monitor", () => {
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
registerWebhookTarget();
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = `${WEBHOOK_PATH}?password=test-password`;
req.url = "/bluebubbles-webhook?password=test-password";
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
@@ -446,13 +426,22 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects unauthorized requests before reading the body", async () => {
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = `${WEBHOOK_PATH}?password=wrong-token`;
req.url = "/bluebubbles-webhook?password=wrong-token";
req.headers = {};
const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = {
@@ -468,43 +457,112 @@ describe("BlueBubbles webhook monitor", () => {
});
it("authenticates via password query parameter", async () => {
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
// Mock non-localhost request
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("authenticates via x-password header", async () => {
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
headers: { "x-password": "secret-token" },
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
{ "x-password": "secret-token" }, // pragma: allowlist secret
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("rejects unauthorized requests with wrong password", async () => {
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=wrong-token`,
body: createWebhookPayload(),
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
});
@@ -512,37 +570,50 @@ describe("BlueBubbles webhook monitor", () => {
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" });
const { config, core, runtime } = createWebhookTargetDeps();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkA = vi.fn();
const sinkB = vi.fn();
const unregisterA = registerWebhookTarget({
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterA = registerBlueBubblesWebhookTarget({
account: accountA,
config,
runtime,
runtime: { log: vi.fn(), error: vi.fn() },
core,
trackForCleanup: false,
path: "/bluebubbles-webhook",
statusSink: sinkA,
}).stop;
const unregisterB = registerWebhookTarget({
});
const unregisterB = registerBlueBubblesWebhookTarget({
account: accountB,
config,
runtime,
runtime: { log: vi.fn(), error: vi.fn() },
core,
trackForCleanup: false,
path: "/bluebubbles-webhook",
statusSink: sinkB,
}).stop;
});
unregister = () => {
unregisterA();
unregisterB();
};
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
@@ -553,37 +624,50 @@ describe("BlueBubbles webhook monitor", () => {
it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" });
const accountWithoutPassword = createMockAccount({ password: undefined });
const { config, core, runtime } = createWebhookTargetDeps();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
const unregisterStrict = registerWebhookTarget({
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
account: accountStrict,
config,
runtime,
runtime: { log: vi.fn(), error: vi.fn() },
core,
trackForCleanup: false,
path: "/bluebubbles-webhook",
statusSink: sinkStrict,
}).stop;
const unregisterNoPassword = registerWebhookTarget({
});
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
account: accountWithoutPassword,
config,
runtime,
runtime: { log: vi.fn(), error: vi.fn() },
core,
trackForCleanup: false,
path: "/bluebubbles-webhook",
statusSink: sinkWithoutPassword,
}).stop;
});
unregister = () => {
unregisterStrict();
unregisterNoPassword();
};
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -593,20 +677,34 @@ describe("BlueBubbles webhook monitor", () => {
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const { config, core, runtime } = createWebhookTargetDeps();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const loopbackUnregister = registerWebhookTarget({
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime,
runtime: { log: vi.fn(), error: vi.fn() },
core,
trackForCleanup: false,
}).stop;
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
remoteAddress,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
@@ -615,8 +713,17 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
registerWebhookTarget({
account: createMockAccount({ password: undefined }),
const account = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const headerVariants: Record<string, string>[] = [
@@ -625,11 +732,26 @@ describe("BlueBubbles webhook monitor", () => {
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
];
for (const headers of headerVariants) {
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
headers,
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
});
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
}
@@ -648,18 +770,36 @@ describe("BlueBubbles webhook monitor", () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
registerWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
await sendWebhookRequest({
body: createWebhookPayload({
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
}),
});
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
@@ -679,18 +819,36 @@ describe("BlueBubbles webhook monitor", () => {
return EMPTY_DISPATCH_RESULT;
});
registerWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
await sendWebhookRequest({
body: createWebhookPayload({
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
}),
});
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();

View File

@@ -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,
};

View File

@@ -329,13 +329,13 @@ describe("diagnostics-otel service", () => {
test("redacts sensitive data from log attributes before export", async () => {
const emitCall = await emitAndCaptureLog({
0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}',
0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', // pragma: allowlist secret
1: "auth configured",
_meta: { logLevelName: "DEBUG", date: new Date() },
});
const tokenAttr = emitCall?.attributes?.["openclaw.token"];
expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456");
expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); // pragma: allowlist secret
if (typeof tokenAttr === "string") {
expect(tokenAttr).toContain("…");
}
@@ -349,7 +349,7 @@ describe("diagnostics-otel service", () => {
emitDiagnosticEvent({
type: "session.state",
state: "waiting",
reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456",
reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", // pragma: allowlist secret
});
const sessionCounter = telemetryState.counters.get("openclaw.session.state");
@@ -362,7 +362,7 @@ describe("diagnostics-otel service", () => {
const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(typeof attrs?.["openclaw.reason"]).toBe("string");
expect(String(attrs?.["openclaw.reason"])).not.toContain(
"ghp_abcdefghijklmnopqrstuvwxyz123456",
"ghp_abcdefghijklmnopqrstuvwxyz123456", // pragma: allowlist secret
);
await service.stop?.(ctx);
});

View File

@@ -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: "<html>viewer</html>",
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: "<html>viewer</html>",
@@ -158,6 +181,30 @@ describe("createDiffsHttpHandler", () => {
expect(res.body).toBe("<html>viewer</html>");
});
it("allows proxied loopback requests when allowRemoteViewer is enabled", async () => {
const artifact = await store.createArtifact({
html: "<html>viewer</html>",
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("<html>viewer</html>");
});
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<string, string>;
}): 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<string, string>;
}): IncomingMessage {
return {
...input,
headers: input.headers ?? {},
socket: { remoteAddress: "203.0.113.10" },
} as unknown as IncomingMessage;
}

View File

@@ -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;

View File

@@ -9,6 +9,35 @@ import type { FeishuConfig } from "./types.js";
const asConfig = (value: Partial<FeishuConfig>) => value as FeishuConfig;
function withEnvVar(key: string, value: string | undefined, run: () => void) {
const prev = process.env[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
try {
run();
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
}
function expectUnresolvedEnvSecretRefError(key: string) {
expect(() =>
resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "env", provider: "default", id: key } as never,
}),
),
).toThrow(/unresolved SecretRef/i);
}
describe("resolveDefaultFeishuAccountId", () => {
it("prefers channels.feishu.defaultAccount when configured", () => {
const cfg = {
@@ -16,8 +45,8 @@ describe("resolveDefaultFeishuAccountId", () => {
feishu: {
defaultAccount: "router-d",
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" },
"router-d": { appId: "cli_router", appSecret: "secret_router" },
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
},
},
},
@@ -32,7 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => {
feishu: {
defaultAccount: "Router D",
accounts: {
"router-d": { appId: "cli_router", appSecret: "secret_router" },
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
},
},
},
@@ -47,8 +76,8 @@ describe("resolveDefaultFeishuAccountId", () => {
feishu: {
defaultAccount: "router-d",
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" },
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret
},
},
},
@@ -62,8 +91,8 @@ describe("resolveDefaultFeishuAccountId", () => {
channels: {
feishu: {
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" },
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret
},
},
},
@@ -90,7 +119,7 @@ describe("resolveDefaultFeishuAccountId", () => {
channels: {
feishu: {
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" },
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
},
},
},
@@ -128,24 +157,9 @@ describe("resolveFeishuCredentials", () => {
it("throws unresolved SecretRef error when env SecretRef points to missing env var", () => {
const key = "FEISHU_APP_SECRET_MISSING_TEST";
const prev = process.env[key];
delete process.env[key];
try {
expect(() =>
resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "env", provider: "default", id: key } as never,
}),
),
).toThrow(/unresolved SecretRef/i);
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
withEnvVar(key, undefined, () => {
expectUnresolvedEnvSecretRefError(key);
});
});
it("resolves env SecretRef objects when unresolved refs are allowed", () => {
@@ -164,7 +178,7 @@ describe("resolveFeishuCredentials", () => {
expect(creds).toEqual({
appId: "cli_123",
appSecret: "secret_from_env",
appSecret: "secret_from_env", // pragma: allowlist secret
encryptKey: undefined,
verificationToken: undefined,
domain: "feishu",
@@ -204,24 +218,9 @@ describe("resolveFeishuCredentials", () => {
it("preserves unresolved SecretRef diagnostics for env refs in default mode", () => {
const key = "FEISHU_APP_SECRET_POLICY_TEST";
const prev = process.env[key];
process.env[key] = "secret_from_env";
try {
expect(() =>
resolveFeishuCredentials(
asConfig({
appId: "cli_123",
appSecret: { source: "env", provider: "default", id: key } as never,
}),
),
).toThrow(/unresolved SecretRef/i);
} finally {
if (prev === undefined) {
delete process.env[key];
} else {
process.env[key] = prev;
}
}
withEnvVar(key, "secret_from_env", () => {
expectUnresolvedEnvSecretRefError(key);
});
});
it("trims and returns credentials when values are valid strings", () => {
@@ -236,7 +235,7 @@ describe("resolveFeishuCredentials", () => {
expect(creds).toEqual({
appId: "cli_123",
appSecret: "secret_456",
appSecret: "secret_456", // pragma: allowlist secret
encryptKey: "enc",
verificationToken: "vt",
domain: "feishu",
@@ -251,9 +250,9 @@ describe("resolveFeishuAccount", () => {
feishu: {
defaultAccount: "router-d",
appId: "top_level_app",
appSecret: "top_level_secret",
appSecret: "top_level_secret", // pragma: allowlist secret
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" },
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
},
},
},
@@ -273,7 +272,7 @@ describe("resolveFeishuAccount", () => {
defaultAccount: "router-d",
accounts: {
default: { enabled: true },
"router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true },
"router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, // pragma: allowlist secret
},
},
},
@@ -292,8 +291,8 @@ describe("resolveFeishuAccount", () => {
feishu: {
defaultAccount: "router-d",
accounts: {
default: { appId: "cli_default", appSecret: "secret_default" },
"router-d": { appId: "cli_router", appSecret: "secret_router" },
default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
},
},
},
@@ -335,7 +334,7 @@ describe("resolveFeishuAccount", () => {
main: {
name: { bad: true },
appId: "cli_123",
appSecret: "secret_456",
appSecret: "secret_456", // pragma: allowlist secret
} as never,
},
},

View File

@@ -1088,7 +1088,7 @@ describe("handleFeishuMessage command authorization", () => {
channels: {
feishu: {
appId: "cli_test",
appSecret: "sec_test",
appSecret: "sec_test", // pragma: allowlist secret
groups: {
"oc-group": {
requireMention: false,
@@ -1151,7 +1151,7 @@ describe("handleFeishuMessage command authorization", () => {
channels: {
feishu: {
appId: "cli_scope_bug",
appSecret: "sec_scope_bug",
appSecret: "sec_scope_bug", // pragma: allowlist secret
groups: {
"oc-group": {
requireMention: false,

View File

@@ -29,7 +29,7 @@ describe("registerFeishuChatTools", () => {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
},
},
@@ -76,7 +76,7 @@ describe("registerFeishuChatTools", () => {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: false },
},
},

View File

@@ -59,7 +59,7 @@ const baseAccount: ResolvedFeishuAccount = {
enabled: true,
configured: true,
appId: "app_123",
appSecret: "secret_123",
appSecret: "secret_123", // pragma: allowlist secret
domain: "feishu",
config: {} as FeishuConfig,
};
@@ -101,8 +101,26 @@ describe("createFeishuClient HTTP timeout", () => {
clearClientCache();
});
const getLastClientHttpInstance = () => {
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1]?.[0] as
| { httpInstance?: { get: (...args: unknown[]) => Promise<unknown> } }
| undefined;
return lastCall?.httpInstance;
};
const expectGetCallTimeout = async (timeout: number) => {
const httpInstance = getLastClientHttpInstance();
expect(httpInstance).toBeDefined();
await httpInstance?.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout }),
);
};
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" });
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
@@ -110,7 +128,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("injects default timeout into HTTP request options", async () => {
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" });
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
@@ -132,7 +150,7 @@ describe("createFeishuClient HTTP timeout", () => {
});
it("allows explicit timeout override per-request", async () => {
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" });
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
@@ -151,45 +169,23 @@ describe("createFeishuClient HTTP timeout", () => {
it("uses config-configured default timeout when provided", async () => {
createFeishuClient({
appId: "app_4",
appSecret: "secret_4",
appSecret: "secret_4", // pragma: allowlist secret
accountId: "timeout-config",
config: { httpTimeoutMs: 45_000 },
});
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
const httpInstance = lastCall.httpInstance;
await httpInstance.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: 45_000 }),
);
await expectGetCallTimeout(45_000);
});
it("falls back to default timeout when configured timeout is invalid", async () => {
createFeishuClient({
appId: "app_5",
appSecret: "secret_5",
appSecret: "secret_5", // pragma: allowlist secret
accountId: "timeout-config-invalid",
config: { httpTimeoutMs: -1 },
});
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
const httpInstance = lastCall.httpInstance;
await httpInstance.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS }),
);
await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MS);
});
it("uses env timeout override when provided and no direct timeout is set", async () => {
@@ -197,21 +193,12 @@ describe("createFeishuClient HTTP timeout", () => {
createFeishuClient({
appId: "app_8",
appSecret: "secret_8",
appSecret: "secret_8", // pragma: allowlist secret
accountId: "timeout-env-override",
config: { httpTimeoutMs: 45_000 },
});
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
await lastCall.httpInstance.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: 60_000 }),
);
await expectGetCallTimeout(60_000);
});
it("prefers direct timeout over env override", async () => {
@@ -219,22 +206,13 @@ describe("createFeishuClient HTTP timeout", () => {
createFeishuClient({
appId: "app_10",
appSecret: "secret_10",
appSecret: "secret_10", // pragma: allowlist secret
accountId: "timeout-direct-override",
httpTimeoutMs: 120_000,
config: { httpTimeoutMs: 45_000 },
});
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
await lastCall.httpInstance.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: 120_000 }),
);
await expectGetCallTimeout(120_000);
});
it("clamps env timeout override to max bound", async () => {
@@ -242,32 +220,23 @@ describe("createFeishuClient HTTP timeout", () => {
createFeishuClient({
appId: "app_9",
appSecret: "secret_9",
appSecret: "secret_9", // pragma: allowlist secret
accountId: "timeout-env-clamp",
});
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
const lastCall = calls[calls.length - 1][0] as {
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
};
await lastCall.httpInstance.get("https://example.com/api");
expect(mockBaseHttpInstance.get).toHaveBeenCalledWith(
"https://example.com/api",
expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MAX_MS }),
);
await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MAX_MS);
});
it("recreates cached client when configured timeout changes", async () => {
createFeishuClient({
appId: "app_6",
appSecret: "secret_6",
appSecret: "secret_6", // pragma: allowlist secret
accountId: "timeout-cache-change",
config: { httpTimeoutMs: 30_000 },
});
createFeishuClient({
appId: "app_6",
appSecret: "secret_6",
appSecret: "secret_6", // pragma: allowlist secret
accountId: "timeout-cache-change",
config: { httpTimeoutMs: 45_000 },
});

View File

@@ -36,7 +36,7 @@ describe("FeishuConfigSchema webhook validation", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
appId: "cli_top",
appSecret: "secret_top",
appSecret: "secret_top", // pragma: allowlist secret
});
expect(result.success).toBe(false);
@@ -52,7 +52,7 @@ describe("FeishuConfigSchema webhook validation", () => {
connectionMode: "webhook",
verificationToken: "token_top",
appId: "cli_top",
appSecret: "secret_top",
appSecret: "secret_top", // pragma: allowlist secret
});
expect(result.success).toBe(true);
@@ -64,7 +64,7 @@ describe("FeishuConfigSchema webhook validation", () => {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main",
appSecret: "secret_main", // pragma: allowlist secret
},
},
});
@@ -86,7 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => {
main: {
connectionMode: "webhook",
appId: "cli_main",
appSecret: "secret_main",
appSecret: "secret_main", // pragma: allowlist secret
},
},
});
@@ -171,7 +171,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
const result = FeishuConfigSchema.safeParse({
defaultAccount: "router-d",
accounts: {
"router-d": { appId: "cli_router", appSecret: "secret_router" },
"router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret
},
});
@@ -182,7 +182,7 @@ describe("FeishuConfigSchema defaultAccount", () => {
const result = FeishuConfigSchema.safeParse({
defaultAccount: "router-d",
accounts: {
backup: { appId: "cli_backup", appSecret: "secret_backup" },
backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret
},
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
function createCountingIterable<T>(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"]);
});
});

View File

@@ -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<string, any>();
for (const block of blocks) {
blockMap.set(block.block_id, block);
}
function collectDescendants(blockMap: Map<string, any>, rootId: string): any[] {
const result: any[] = [];
const visited = new Set<string>();
@@ -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<string>();
const blockMap = new Map<string, any>();
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

View File

@@ -27,8 +27,8 @@ describe("feishu_doc account selection", () => {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, // pragma: allowlist secret
},
},
},

View File

@@ -73,7 +73,7 @@ function buildConfig(params: {
[params.accountId]: {
enabled: true,
appId: "cli_test",
appSecret: "secret_test",
appSecret: "secret_test", // pragma: allowlist secret
connectionMode: "webhook",
webhookHost: "127.0.0.1",
webhookPort: params.port,

View File

@@ -17,6 +17,44 @@ const baseStatusContext = {
accountOverrides: {},
};
async function withEnvVars(values: Record<string, string | undefined>, run: () => Promise<void>) {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
await run();
} finally {
for (const [key, prior] of previous.entries()) {
if (prior === undefined) {
delete process.env[key];
} else {
process.env[key] = prior;
}
}
}
}
async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) {
return await feishuOnboardingAdapter.getStatus({
cfg: {
channels: {
feishu: {
appId: { source: "env", id: params.appIdKey, provider: "default" },
appSecret: { source: "env", id: params.appSecretKey, provider: "default" },
},
},
} as never,
...baseStatusContext,
});
}
describe("feishuOnboardingAdapter.configure", () => {
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
const text = vi
@@ -61,7 +99,7 @@ describe("feishuOnboardingAdapter.getStatus", () => {
accounts: {
main: {
appId: "",
appSecret: "secret_123",
appSecret: "sample-app-credential", // pragma: allowlist secret
},
},
},
@@ -75,73 +113,31 @@ describe("feishuOnboardingAdapter.getStatus", () => {
it("treats env SecretRef appId as not configured when env var is missing", async () => {
const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST";
const appSecretKey = "FEISHU_APP_SECRET_STATUS_MISSING_TEST";
const prevAppId = process.env[appIdKey];
const prevAppSecret = process.env[appSecretKey];
delete process.env[appIdKey];
process.env[appSecretKey] = "secret_env_456";
try {
const status = await feishuOnboardingAdapter.getStatus({
cfg: {
channels: {
feishu: {
appId: { source: "env", id: appIdKey, provider: "default" },
appSecret: { source: "env", id: appSecretKey, provider: "default" },
},
},
} as never,
...baseStatusContext,
});
expect(status.configured).toBe(false);
} finally {
if (prevAppId === undefined) {
delete process.env[appIdKey];
} else {
process.env[appIdKey] = prevAppId;
}
if (prevAppSecret === undefined) {
delete process.env[appSecretKey];
} else {
process.env[appSecretKey] = prevAppSecret;
}
}
const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret
await withEnvVars(
{
[appIdKey]: undefined,
[appSecretKey]: "env-credential-456", // pragma: allowlist secret
},
async () => {
const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey });
expect(status.configured).toBe(false);
},
);
});
it("treats env SecretRef appId/appSecret as configured in status", async () => {
const appIdKey = "FEISHU_APP_ID_STATUS_TEST";
const appSecretKey = "FEISHU_APP_SECRET_STATUS_TEST";
const prevAppId = process.env[appIdKey];
const prevAppSecret = process.env[appSecretKey];
process.env[appIdKey] = "cli_env_123";
process.env[appSecretKey] = "secret_env_456";
try {
const status = await feishuOnboardingAdapter.getStatus({
cfg: {
channels: {
feishu: {
appId: { source: "env", id: appIdKey, provider: "default" },
appSecret: { source: "env", id: appSecretKey, provider: "default" },
},
},
} as never,
...baseStatusContext,
});
expect(status.configured).toBe(true);
} finally {
if (prevAppId === undefined) {
delete process.env[appIdKey];
} else {
process.env[appIdKey] = prevAppId;
}
if (prevAppSecret === undefined) {
delete process.env[appSecretKey];
} else {
process.env[appSecretKey] = prevAppSecret;
}
}
const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_TEST"; // pragma: allowlist secret
await withEnvVars(
{
[appIdKey]: "cli_env_123",
[appSecretKey]: "env-credential-456", // pragma: allowlist secret
},
async () => {
const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey });
expect(status.configured).toBe(true);
},
);
});
});

View File

@@ -34,7 +34,7 @@ describe("probeFeishu", () => {
});
it("returns error when appId is missing", async () => {
const result = await probeFeishu({ appSecret: "secret" } as never);
const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret
expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
});
@@ -49,7 +49,7 @@ describe("probeFeishu", () => {
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
});
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
expect(result).toEqual({
ok: true,
appId: "cli_123",
@@ -65,7 +65,7 @@ describe("probeFeishu", () => {
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
});
await probeFeishu({ appId: "cli_123", appSecret: "secret" });
await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -98,7 +98,7 @@ describe("probeFeishu", () => {
abortController.abort();
const result = await probeFeishu(
{ appId: "cli_123", appSecret: "secret" },
{ appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret
{ abortSignal: abortController.signal },
);
@@ -111,7 +111,7 @@ describe("probeFeishu", () => {
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
});
const creds = { appId: "cli_123", appSecret: "secret" };
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
const first = await probeFeishu(creds);
const second = await probeFeishu(creds);
@@ -128,7 +128,7 @@ describe("probeFeishu", () => {
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
});
const creds = { appId: "cli_123", appSecret: "secret" };
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
await probeFeishu(creds);
expect(requestFn).toHaveBeenCalledTimes(1);
@@ -148,7 +148,7 @@ describe("probeFeishu", () => {
const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
createFeishuClientMock.mockReturnValue({ request: requestFn });
const creds = { appId: "cli_123", appSecret: "secret" };
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
const first = await probeFeishu(creds);
const second = await probeFeishu(creds);
expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
@@ -170,7 +170,7 @@ describe("probeFeishu", () => {
const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
createFeishuClientMock.mockReturnValue({ request: requestFn });
const creds = { appId: "cli_123", appSecret: "secret" };
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
const first = await probeFeishu(creds);
const second = await probeFeishu(creds);
expect(first).toMatchObject({ ok: false, error: "network error" });
@@ -192,15 +192,15 @@ describe("probeFeishu", () => {
bot: { bot_name: "Bot1", open_id: "ou_1" },
});
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(1);
// Different appId should trigger a new API call
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" });
await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(2);
// Same appId + appSecret as first call should return cached
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(2);
});
@@ -211,12 +211,12 @@ describe("probeFeishu", () => {
});
// First account with appId + secret A
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" });
await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(1);
// Second account with same appId but different secret (e.g. after rotation)
// must NOT reuse the cached result
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" });
await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(2);
});
@@ -227,14 +227,14 @@ describe("probeFeishu", () => {
});
// Two accounts with same appId+appSecret but different accountIds are cached separately
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(1);
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" });
await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(2);
// Same accountId should return cached
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
expect(requestFn).toHaveBeenCalledTimes(2);
});
@@ -244,7 +244,7 @@ describe("probeFeishu", () => {
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
});
const creds = { appId: "cli_123", appSecret: "secret" };
const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
await probeFeishu(creds);
expect(requestFn).toHaveBeenCalledTimes(1);
@@ -260,7 +260,7 @@ describe("probeFeishu", () => {
data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
});
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
expect(result).toEqual({
ok: true,
appId: "cli_123",

View File

@@ -106,6 +106,28 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
});
function setupNonStreamingAutoDispatcher() {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: false,
},
});
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: { log: vi.fn(), error: vi.fn() } as never,
chatId: "oc_chat",
});
return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
}
it("skips typing indicator when account typingIndicator is disabled", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
@@ -312,25 +334,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("suppresses duplicate final text while still sending media", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: false,
},
});
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: { log: vi.fn(), error: vi.fn() } as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
const options = setupNonStreamingAutoDispatcher();
await options.deliver({ text: "plain final" }, { kind: "final" });
await options.deliver(
{ text: "plain final", mediaUrl: "https://example.com/a.png" },
@@ -352,25 +356,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
it("keeps distinct non-streaming final payloads", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: false,
},
});
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: { log: vi.fn(), error: vi.fn() } as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
const options = setupNonStreamingAutoDispatcher();
await options.deliver({ text: "notice header" }, { kind: "final" });
await options.deliver({ text: "actual answer body" }, { kind: "final" });

View File

@@ -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,
};

View File

@@ -35,12 +35,12 @@ function createConfig(params: {
accounts: {
a: {
appId: "app-a",
appSecret: "sec-a",
appSecret: "sec-a", // pragma: allowlist secret
tools: params.toolsA,
},
b: {
appId: "app-b",
appSecret: "sec-b",
appSecret: "sec-b", // pragma: allowlist secret
tools: params.toolsB,
},
},

View File

@@ -308,7 +308,7 @@ describe("loginGeminiCliOAuth", () => {
beforeEach(() => {
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_ID = "test-client-id.apps.googleusercontent.com";
process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret";
process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; // pragma: allowlist secret
delete process.env.GEMINI_CLI_OAUTH_CLIENT_ID;
delete process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET;
delete process.env.GOOGLE_CLOUD_PROJECT;

View File

@@ -81,7 +81,7 @@ describe("sendGoogleChatMessage", () => {
});
const [url, init] = fetchMock.mock.calls[0] ?? [];
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); // pragma: allowlist secret
expect(JSON.parse(String(init?.body))).toMatchObject({
text: "hello",
thread: { name: "spaces/AAA/threads/xyz" },

View File

@@ -12,26 +12,51 @@ vi.mock("./api.js", () => ({
import { googlechatPlugin } from "./channel.js";
import { setGoogleChatRuntime } from "./runtime.js";
function createGoogleChatCfg(): OpenClawConfig {
return {
channels: {
googlechat: {
enabled: true,
serviceAccount: {
type: "service_account",
client_email: "bot@example.com",
private_key: "test-key", // pragma: allowlist secret
token_uri: "https://oauth2.googleapis.com/token",
},
},
},
};
}
function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) {
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from(params.loadBytes),
fileName: params.loadFileName,
contentType: "image/png",
}));
const fetchRemoteMedia = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
setGoogleChatRuntime({
media: { loadWebMedia },
channel: {
media: { fetchRemoteMedia },
text: { chunkMarkdownText: (text: string) => [text] },
},
} as unknown as PluginRuntime);
return { loadWebMedia, fetchRemoteMedia };
}
describe("googlechatPlugin outbound sendMedia", () => {
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
contentType: "image/png",
}));
const fetchRemoteMedia = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
setGoogleChatRuntime({
media: { loadWebMedia },
channel: {
media: { fetchRemoteMedia },
text: { chunkMarkdownText: (text: string) => [text] },
},
} as unknown as PluginRuntime);
const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({
loadFileName: "image.png",
loadBytes: "image-bytes",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-1",
@@ -40,19 +65,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
messageName: "spaces/AAA/messages/msg-1",
});
const cfg: OpenClawConfig = {
channels: {
googlechat: {
enabled: true,
serviceAccount: {
type: "service_account",
client_email: "bot@example.com",
private_key: "test-key",
token_uri: "https://oauth2.googleapis.com/token",
},
},
},
};
const cfg = createGoogleChatCfg();
const result = await googlechatPlugin.outbound?.sendMedia?.({
cfg,
@@ -91,24 +104,10 @@ describe("googlechatPlugin outbound sendMedia", () => {
});
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from("should-not-be-used"),
fileName: "unused.png",
contentType: "image/png",
}));
const fetchRemoteMedia = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
setGoogleChatRuntime({
media: { loadWebMedia },
channel: {
media: { fetchRemoteMedia },
text: { chunkMarkdownText: (text: string) => [text] },
},
} as unknown as PluginRuntime);
const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({
loadFileName: "unused.png",
loadBytes: "should-not-be-used",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-2",
@@ -117,19 +116,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
messageName: "spaces/AAA/messages/msg-2",
});
const cfg: OpenClawConfig = {
channels: {
googlechat: {
enabled: true,
serviceAccount: {
type: "service_account",
client_email: "bot@example.com",
private_key: "test-key",
token_uri: "https://oauth2.googleapis.com/token",
},
},
},
};
const cfg = createGoogleChatCfg();
const result = await googlechatPlugin.outbound?.sendMedia?.({
cfg,

View File

@@ -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,
};

View File

@@ -496,7 +496,13 @@ describe("createMattermostInteractionHandler", () => {
return res as unknown as ServerResponse & { headers: Record<string, string>; body: string };
}
it("accepts callback requests from an allowlisted source IP", async () => {
async function runApproveInteraction(params?: {
actionName?: string;
allowedSourceIps?: string[];
trustedProxies?: string[];
remoteAddress?: string;
headers?: Record<string, string>;
}) {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const requestLog: Array<{ path: string; method?: string }> = [];
@@ -511,18 +517,22 @@ describe("createMattermostInteractionHandler", () => {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
attachments: [
{ actions: [{ id: "approve", name: params?.actionName ?? "Approve" }] },
],
},
};
},
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
allowedSourceIps: ["198.51.100.8"],
allowedSourceIps: params?.allowedSourceIps,
trustedProxies: params?.trustedProxies,
});
const req = createReq({
remoteAddress: "198.51.100.8",
remoteAddress: params?.remoteAddress,
headers: params?.headers,
body: {
user_id: "user-1",
user_name: "alice",
@@ -532,8 +542,45 @@ describe("createMattermostInteractionHandler", () => {
},
});
const res = createRes();
await handler(req, res);
return { res, requestLog };
}
async function runInvalidActionRequest(actionId: string) {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: actionId, name: actionId }] }],
},
}),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
return res;
}
it("accepts callback requests from an allowlisted source IP", async () => {
const { res, requestLog } = await runApproveInteraction({
allowedSourceIps: ["198.51.100.8"],
remoteAddress: "198.51.100.8",
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
@@ -544,43 +591,12 @@ describe("createMattermostInteractionHandler", () => {
});
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async (_path: string, init?: { method?: string }) => {
if (init?.method === "PUT") {
return { id: "post-1" };
}
return {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
},
};
},
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
const { res } = await runApproveInteraction({
allowedSourceIps: ["198.51.100.8"],
trustedProxies: ["127.0.0.1"],
});
const req = createReq({
remoteAddress: "127.0.0.1",
headers: { "x-forwarded-for": "198.51.100.8" },
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
@@ -703,75 +719,17 @@ describe("createMattermostInteractionHandler", () => {
});
it("rejects requests when the action is not present on the fetched post", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async () => ({
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "reject", name: "Reject" }] }],
},
}),
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
});
const req = createReq({
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
const res = await runInvalidActionRequest("reject");
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Unknown action");
});
it("accepts actions when the button name matches the action id", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const requestLog: Array<{ path: string; method?: string }> = [];
const handler = createMattermostInteractionHandler({
client: {
request: async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
if (init?.method === "PUT") {
return { id: "post-1" };
}
return {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "approve" }] }],
},
};
},
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
const { res, requestLog } = await runApproveInteraction({
actionName: "approve",
});
const req = createReq({
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
expect(requestLog).toEqual([

View File

@@ -74,12 +74,12 @@ describe("looksLikeMattermostTargetId", () => {
it("recognizes 26-char alphanumeric Mattermost IDs", () => {
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true);
expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true);
expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true);
expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); // pragma: allowlist secret
});
it("recognizes DM channel format (26__26)", () => {
expect(
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"),
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), // pragma: allowlist secret
).toBe(true);
});
@@ -91,6 +91,6 @@ describe("looksLikeMattermostTargetId", () => {
});
it("rejects strings longer than 26 chars that are not DM format", () => {
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false);
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); // pragma: allowlist secret
});
});

View File

@@ -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,
};

View File

@@ -140,7 +140,7 @@ function createConfig(port: number): OpenClawConfig {
msteams: {
enabled: true,
appId: "app-id",
appPassword: "app-password",
appPassword: "app-password", // pragma: allowlist secret
tenantId: "tenant-id",
webhook: {
port,

View File

@@ -35,7 +35,7 @@ describe("resolveMSTeamsCredentials", () => {
expect(resolved).toEqual({
appId: "app-id",
appPassword: "app-password",
appPassword: "app-password", // pragma: allowlist secret
tenantId: "tenant-id",
});
});

View File

@@ -21,11 +21,11 @@ function buildAccount(): ResolvedNextcloudTalkAccount {
accountId: "default",
enabled: true,
baseUrl: "https://nextcloud.example.com",
secret: "secret",
secretSource: "config",
secret: "secret", // pragma: allowlist secret
secretSource: "config", // pragma: allowlist secret
config: {
baseUrl: "https://nextcloud.example.com",
botSecret: "secret",
botSecret: "secret", // pragma: allowlist secret
webhookPath: "/nextcloud-talk-webhook",
webhookPort: 8788,
},

View File

@@ -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,
};

View File

@@ -8,7 +8,7 @@ const hoisted = vi.hoisted(() => ({
resolveNextcloudTalkAccount: vi.fn(() => ({
accountId: "default",
baseUrl: "https://nextcloud.example.com",
secret: "secret-value",
secret: "secret-value", // pragma: allowlist secret
})),
generateNextcloudTalkSignature: vi.fn(() => ({
random: "r",

View File

@@ -51,8 +51,8 @@ describe("nostr outbound cfg threading", () => {
accountId: "default",
enabled: true,
configured: true,
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // pragma: allowlist secret
publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", // pragma: allowlist secret
relays: ["wss://relay.example.com"],
config: {},
},
@@ -63,7 +63,7 @@ describe("nostr outbound cfg threading", () => {
const cfg = {
channels: {
nostr: {
privateKey: "resolved-nostr-private-key",
privateKey: "resolved-nostr-private-key", // pragma: allowlist secret
},
},
};

View File

@@ -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" }),

View File

@@ -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" });

View File

@@ -144,7 +144,7 @@ describe("slackPlugin config", () => {
slack: {
mode: "http",
botToken: "xoxb-http",
signingSecret: "secret-http",
signingSecret: "secret-http", // pragma: allowlist secret
},
},
};
@@ -214,9 +214,9 @@ describe("slackPlugin config", () => {
configured: true,
mode: "http",
botTokenStatus: "available",
signingSecretStatus: "configured_unavailable",
signingSecretStatus: "configured_unavailable", // pragma: allowlist secret
botTokenSource: "config",
signingSecretSource: "config",
signingSecretSource: "config", // pragma: allowlist secret
config: {
mode: "http",
botToken: "xoxb-http",

View File

@@ -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

View File

@@ -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,
}),
);
});

View File

@@ -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<string | null>;
@@ -364,6 +365,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
chatType: "direct",
sessionKey,
accountId: account.accountId,
commandAuthorized: auth.allowed,
chatUserId: replyUserId,
});

View File

@@ -129,7 +129,7 @@ describe("telegramPlugin duplicate token guard", () => {
cfg.channels!.telegram!.accounts!.ops = {
...cfg.channels!.telegram!.accounts!.ops,
webhookUrl: "https://example.test/telegram-webhook",
webhookSecret: "secret",
webhookSecret: "secret", // pragma: allowlist secret
webhookPort: 9876,
};

View File

@@ -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,
};

View File

@@ -1,5 +1,3 @@
import fsp from "node:fs/promises";
import path from "node:path";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
@@ -14,7 +12,6 @@ import {
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
resolvePreferredOpenClawTmpDir,
} from "openclaw/plugin-sdk/zalouser";
import {
listZalouserAccountIds,
@@ -22,6 +19,7 @@ import {
resolveZalouserAccountSync,
checkZcaAuthenticated,
} from "./accounts.js";
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
import {
logoutZaloProfile,
resolveZaloAllowFromEntries,
@@ -103,25 +101,6 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
);
}
async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
const trimmed = qrDataUrl.trim();
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
const base64 = (match?.[1] ?? "").trim();
if (!base64) {
return null;
}
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
const filePath = path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-zalouser-qr-${safeProfile}.png`,
);
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
return filePath;
}
async function promptZalouserAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;

View File

@@ -26,9 +26,9 @@ const ensureSupportedNodeVersion = () => {
process.stderr.write(
`openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
"If you use nvm, run:\n" +
" nvm install 22\n" +
" nvm use 22\n" +
" nvm alias default 22\n",
` nvm install ${MIN_NODE_MAJOR}\n` +
` nvm use ${MIN_NODE_MAJOR}\n` +
` nvm alias default ${MIN_NODE_MAJOR}\n`,
);
process.exit(1);
};

View File

@@ -10,6 +10,8 @@ import {
} from "./client.js";
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
const envVar = (...parts: string[]) => parts.join("_");
function makePermissionRequest(
overrides: Partial<RequestPermissionRequest> = {},
): RequestPermissionRequest {
@@ -62,42 +64,47 @@ describe("resolveAcpClientSpawnEnv", () => {
});
it("strips skill-injected env keys when stripKeys is provided", () => {
const stripKeys = new Set(["OPENAI_API_KEY", "ELEVENLABS_API_KEY"]);
const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY");
const elevenLabsApiKeyEnv = envVar("ELEVENLABS", "API", "KEY");
const anthropicApiKeyEnv = envVar("ANTHROPIC", "API", "KEY");
const stripKeys = new Set([openAiApiKeyEnv, elevenLabsApiKeyEnv]);
const env = resolveAcpClientSpawnEnv(
{
PATH: "/usr/bin",
OPENAI_API_KEY: "sk-leaked-from-skill",
ELEVENLABS_API_KEY: "el-leaked",
ANTHROPIC_API_KEY: "sk-keep-this",
[openAiApiKeyEnv]: "openai-test-value", // pragma: allowlist secret
[elevenLabsApiKeyEnv]: "elevenlabs-test-value", // pragma: allowlist secret
[anthropicApiKeyEnv]: "anthropic-test-value", // pragma: allowlist secret
},
{ stripKeys },
);
expect(env.PATH).toBe("/usr/bin");
expect(env.OPENCLAW_SHELL).toBe("acp-client");
expect(env.ANTHROPIC_API_KEY).toBe("sk-keep-this");
expect(env.ANTHROPIC_API_KEY).toBe("anthropic-test-value");
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.ELEVENLABS_API_KEY).toBeUndefined();
});
it("does not modify the original baseEnv when stripping keys", () => {
const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY");
const baseEnv: NodeJS.ProcessEnv = {
OPENAI_API_KEY: "sk-original",
[openAiApiKeyEnv]: "openai-original", // pragma: allowlist secret
PATH: "/usr/bin",
};
const stripKeys = new Set(["OPENAI_API_KEY"]);
const stripKeys = new Set([openAiApiKeyEnv]);
resolveAcpClientSpawnEnv(baseEnv, { stripKeys });
expect(baseEnv.OPENAI_API_KEY).toBe("sk-original");
expect(baseEnv.OPENAI_API_KEY).toBe("openai-original");
});
it("preserves OPENCLAW_SHELL even when stripKeys contains it", () => {
const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY");
const env = resolveAcpClientSpawnEnv(
{
OPENCLAW_SHELL: "skill-overridden",
OPENAI_API_KEY: "sk-leaked",
[openAiApiKeyEnv]: "openai-leaked", // pragma: allowlist secret
},
{ stripKeys: new Set(["OPENCLAW_SHELL", "OPENAI_API_KEY"]) },
{ stripKeys: new Set(["OPENCLAW_SHELL", openAiApiKeyEnv]) },
);
expect(env.OPENCLAW_SHELL).toBe("acp-client");

View File

@@ -180,7 +180,7 @@ describe("serveAcpGateway startup", () => {
it("passes resolved SecretInput gateway credentials to the ACP gateway client", async () => {
mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({
token: undefined,
password: "resolved-secret-password",
password: "resolved-secret-password", // pragma: allowlist secret
});
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
@@ -195,7 +195,7 @@ describe("serveAcpGateway startup", () => {
);
expect(mockState.gatewayAuth[0]).toEqual({
token: undefined,
password: "resolved-secret-password",
password: "resolved-secret-password", // pragma: allowlist secret
});
const gateway = getMockGateway();

View File

@@ -23,8 +23,8 @@ vi.mock("@mariozechner/pi-ai", async () => {
...actual,
getOAuthApiKey: getOAuthApiKeyMock,
getOAuthProviders: () => [
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" },
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" },
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
],
};
});
@@ -91,7 +91,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
});
expect(result).toEqual({
apiKey: "cached-access-token",
apiKey: "cached-access-token", // pragma: allowlist secret
provider: "openai-codex",
email: undefined,
});

View File

@@ -45,6 +45,20 @@ async function resolveWithConfig(params: {
});
}
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
const previous = process.env[key];
process.env[key] = value;
try {
return await run();
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
}
describe("resolveApiKeyForProfile config compatibility", () => {
it("accepts token credentials when config mode is oauth", async () => {
const profileId = "anthropic:token";
@@ -263,9 +277,7 @@ describe("resolveApiKeyForProfile secret refs", () => {
it("resolves token tokenRef from env", async () => {
const profileId = "github-copilot:default";
const previous = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = "gh-ref-token";
try {
await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => {
const result = await resolveApiKeyForProfile({
cfg: cfgFor(profileId, "github-copilot", "token"),
store: {
@@ -286,20 +298,12 @@ describe("resolveApiKeyForProfile secret refs", () => {
provider: "github-copilot",
email: undefined,
});
} finally {
if (previous === undefined) {
delete process.env.GITHUB_TOKEN;
} else {
process.env.GITHUB_TOKEN = previous;
}
}
});
});
it("resolves token tokenRef without inline token when expires is absent", async () => {
const profileId = "github-copilot:no-inline-token";
const previous = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = "gh-ref-token";
try {
await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => {
const result = await resolveApiKeyForProfile({
cfg: cfgFor(profileId, "github-copilot", "token"),
store: {
@@ -319,13 +323,7 @@ describe("resolveApiKeyForProfile secret refs", () => {
provider: "github-copilot",
email: undefined,
});
} finally {
if (previous === undefined) {
delete process.env.GITHUB_TOKEN;
} else {
process.env.GITHUB_TOKEN = previous;
}
}
});
});
it("resolves inline ${ENV} api_key values", async () => {

View File

@@ -54,7 +54,7 @@ describe("compaction toolResult details stripping", () => {
messages,
// Minimal shape; compaction won't use these fields in our mocked generateSummary.
model: { id: "mock", name: "mock", contextWindow: 10000, maxTokens: 1000 } as never,
apiKey: "test",
apiKey: "test", // pragma: allowlist secret
signal: new AbortController().signal,
reserveTokens: 100,
maxChunkTokens: 5000,

View File

@@ -54,6 +54,34 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
});
}
async function withFetchPathTest(
mockFetch: ReturnType<typeof vi.fn>,
runAssertions: () => Promise<void>,
) {
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
vi.stubGlobal("fetch", mockFetch);
try {
await runAssertions();
} finally {
if (origNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = origNodeEnv;
}
if (origVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
}
describe("discoverKilocodeModels", () => {
it("returns static catalog in test environment", async () => {
// Default vitest env — should return static catalog without fetching
@@ -77,12 +105,6 @@ describe("discoverKilocodeModels", () => {
describe("discoverKilocodeModels (fetch path)", () => {
it("parses gateway models with correct pricing conversion", async () => {
// Temporarily unset test env flags to exercise the fetch path
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
@@ -90,9 +112,7 @@ describe("discoverKilocodeModels (fetch path)", () => {
data: [makeAutoModel(), makeGatewayModel()],
}),
});
vi.stubGlobal("fetch", mockFetch);
try {
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
// Should have fetched from the gateway URL
@@ -123,68 +143,31 @@ describe("discoverKilocodeModels (fetch path)", () => {
// Verify context/tokens
expect(sonnet?.contextWindow).toBe(200000);
expect(sonnet?.maxTokens).toBe(8192);
} finally {
process.env.NODE_ENV = origNodeEnv;
if (origVitest !== undefined) {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
});
});
it("falls back to static catalog on network error", async () => {
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockRejectedValue(new Error("network error"));
vi.stubGlobal("fetch", mockFetch);
try {
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models.length).toBeGreaterThan(0);
expect(models.some((m) => m.id === "kilo/auto")).toBe(true);
} finally {
process.env.NODE_ENV = origNodeEnv;
if (origVitest !== undefined) {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
});
});
it("falls back to static catalog on HTTP error", async () => {
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
vi.stubGlobal("fetch", mockFetch);
try {
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models.length).toBeGreaterThan(0);
expect(models.some((m) => m.id === "kilo/auto")).toBe(true);
} finally {
process.env.NODE_ENV = origNodeEnv;
if (origVitest !== undefined) {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
});
});
it("ensures kilo/auto is present even when API doesn't return it", async () => {
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
@@ -192,27 +175,14 @@ describe("discoverKilocodeModels (fetch path)", () => {
data: [makeGatewayModel()], // no kilo/auto
}),
});
vi.stubGlobal("fetch", mockFetch);
try {
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models.some((m) => m.id === "kilo/auto")).toBe(true);
expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true);
} finally {
process.env.NODE_ENV = origNodeEnv;
if (origVitest !== undefined) {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
});
});
it("detects text-only models without image modality", async () => {
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const textOnlyModel = makeGatewayModel({
id: "some/text-model",
architecture: {
@@ -226,28 +196,15 @@ describe("discoverKilocodeModels (fetch path)", () => {
ok: true,
json: () => Promise.resolve({ data: [textOnlyModel] }),
});
vi.stubGlobal("fetch", mockFetch);
try {
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const textModel = models.find((m) => m.id === "some/text-model");
expect(textModel?.input).toEqual(["text"]);
expect(textModel?.reasoning).toBe(false);
} finally {
process.env.NODE_ENV = origNodeEnv;
if (origVitest !== undefined) {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
});
});
it("keeps a later valid duplicate when an earlier entry is malformed", async () => {
const origNodeEnv = process.env.NODE_ENV;
const origVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
const malformedAutoModel = makeAutoModel({
name: "Broken Kilo Auto",
pricing: undefined,
@@ -260,21 +217,13 @@ describe("discoverKilocodeModels (fetch path)", () => {
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
});
vi.stubGlobal("fetch", mockFetch);
try {
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const auto = models.find((m) => m.id === "kilo/auto");
expect(auto).toBeDefined();
expect(auto?.name).toBe("Kilo: Auto");
expect(auto?.cost.input).toBeCloseTo(5.0);
expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true);
} finally {
process.env.NODE_ENV = origNodeEnv;
if (origVitest !== undefined) {
process.env.VITEST = origVitest;
}
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -188,7 +188,7 @@ describe("memory search config", () => {
provider: "openai",
remote: {
baseUrl: "https://default.example/v1",
apiKey: "default-key",
apiKey: "default-key", // pragma: allowlist secret
headers: { "X-Default": "on" },
},
},
@@ -209,7 +209,7 @@ describe("memory search config", () => {
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.remote).toEqual({
baseUrl: "https://agent.example/v1",
apiKey: "default-key",
apiKey: "default-key", // pragma: allowlist secret
headers: { "X-Default": "on" },
batch: {
enabled: false,
@@ -228,7 +228,7 @@ describe("memory search config", () => {
memorySearch: {
provider: "openai",
remote: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
headers: { "X-Default": "on" },
},
},

View File

@@ -3,30 +3,31 @@ import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
describe("minimaxUnderstandImage apiKey normalization", () => {
const priorFetch = global.fetch;
const apiResponse = JSON.stringify({
base_resp: { status_code: 0, status_msg: "ok" },
content: "ok",
});
afterEach(() => {
global.fetch = priorFetch;
vi.restoreAllMocks();
});
it("strips embedded CR/LF before sending Authorization header", async () => {
async function runNormalizationCase(apiKey: string) {
const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
expect(auth).toBe("Bearer minimax-test-key");
return new Response(
JSON.stringify({
base_resp: { status_code: 0, status_msg: "ok" },
content: "ok",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
return new Response(apiResponse, {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
global.fetch = withFetchPreconnect(fetchSpy);
const { minimaxUnderstandImage } = await import("./minimax-vlm.js");
const text = await minimaxUnderstandImage({
apiKey: "minimax-test-\r\nkey",
apiKey,
prompt: "hi",
imageDataUrl: "data:image/png;base64,AAAA",
apiHost: "https://api.minimax.io",
@@ -34,32 +35,13 @@ describe("minimaxUnderstandImage apiKey normalization", () => {
expect(text).toBe("ok");
expect(fetchSpy).toHaveBeenCalled();
}
it("strips embedded CR/LF before sending Authorization header", async () => {
await runNormalizationCase("minimax-test-\r\nkey");
});
it("drops non-Latin1 characters from apiKey before sending Authorization header", async () => {
const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
expect(auth).toBe("Bearer minimax-test-key");
return new Response(
JSON.stringify({
base_resp: { status_code: 0, status_msg: "ok" },
content: "ok",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
global.fetch = withFetchPreconnect(fetchSpy);
const { minimaxUnderstandImage } = await import("./minimax-vlm.js");
const text = await minimaxUnderstandImage({
apiKey: "minimax-\u0417\u2502test-key",
prompt: "hi",
imageDataUrl: "data:image/png;base64,AAAA",
apiHost: "https://api.minimax.io",
});
expect(text).toBe("ok");
expect(fetchSpy).toHaveBeenCalled();
await runNormalizationCase("minimax-\u0417\u2502test-key");
});
});

View File

@@ -0,0 +1,42 @@
export const PROVIDER_ENV_API_KEY_CANDIDATES: Record<string, string[]> = {
"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())];
}

View File

@@ -32,7 +32,7 @@ describe("resolveModelAuthLabel", () => {
"github-copilot:default": {
type: "token",
provider: "github-copilot",
token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // pragma: allowlist secret
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
},
},
@@ -52,7 +52,7 @@ describe("resolveModelAuthLabel", () => {
});
it("does not include api-key value in label for api-key profiles", () => {
const shortSecret = "abc123";
const shortSecret = "abc123"; // pragma: allowlist secret
ensureAuthProfileStoreMock.mockReturnValue({
version: 1,
profiles: {

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -7,6 +7,8 @@ import { withEnvAsync } from "../test-utils/env.js";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
const envVar = (...parts: string[]) => parts.join("_");
const oauthFixture = {
access: "access-token",
refresh: "refresh-token",
@@ -191,7 +193,7 @@ describe("getApiKeyForModel", () => {
await withEnvAsync(
{
ZAI_API_KEY: undefined,
Z_AI_API_KEY: "zai-test-key",
Z_AI_API_KEY: "zai-test-key", // pragma: allowlist secret
},
async () => {
const resolved = await resolveApiKeyForProvider({
@@ -205,7 +207,8 @@ describe("getApiKeyForModel", () => {
});
it("resolves Synthetic API key from env", async () => {
await withEnvAsync({ SYNTHETIC_API_KEY: "synthetic-test-key" }, async () => {
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "synthetic",
store: { version: 1, profiles: {} },
@@ -216,7 +219,8 @@ describe("getApiKeyForModel", () => {
});
it("resolves Qianfan API key from env", async () => {
await withEnvAsync({ QIANFAN_API_KEY: "qianfan-test-key" }, async () => {
await withEnvAsync({ [envVar("QIANFAN", "API", "KEY")]: "qianfan-test-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "qianfan",
store: { version: 1, profiles: {} },
@@ -250,7 +254,8 @@ describe("getApiKeyForModel", () => {
});
it("prefers explicit OLLAMA_API_KEY over synthetic local key", async () => {
await withEnvAsync({ OLLAMA_API_KEY: "env-ollama-key" }, async () => {
await withEnvAsync({ [envVar("OLLAMA", "API", "KEY")]: "env-ollama-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "ollama",
store: { version: 1, profiles: {} },
@@ -283,7 +288,8 @@ describe("getApiKeyForModel", () => {
});
it("resolves Vercel AI Gateway API key from env", async () => {
await withEnvAsync({ AI_GATEWAY_API_KEY: "gateway-test-key" }, async () => {
await withEnvAsync({ [envVar("AI_GATEWAY", "API", "KEY")]: "gateway-test-key" }, async () => {
// pragma: allowlist secret
const resolved = await resolveApiKeyForProvider({
provider: "vercel-ai-gateway",
store: { version: 1, profiles: {} },
@@ -296,9 +302,9 @@ describe("getApiKeyForModel", () => {
it("prefers Bedrock bearer token over access keys and profile", async () => {
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: "bedrock-token",
AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", // pragma: allowlist secret
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
[envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret
AWS_PROFILE: "profile",
},
expectedSource: "AWS_BEARER_TOKEN_BEDROCK",
@@ -310,7 +316,7 @@ describe("getApiKeyForModel", () => {
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
[envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret
AWS_PROFILE: "profile",
},
expectedSource: "AWS_ACCESS_KEY_ID",
@@ -330,7 +336,8 @@ describe("getApiKeyForModel", () => {
});
it("accepts VOYAGE_API_KEY for voyage", async () => {
await withEnvAsync({ VOYAGE_API_KEY: "voyage-test-key" }, async () => {
await withEnvAsync({ [envVar("VOYAGE", "API", "KEY")]: "voyage-test-key" }, async () => {
// pragma: allowlist secret
const voyage = await resolveApiKeyForProvider({
provider: "voyage",
store: { version: 1, profiles: {} },
@@ -341,7 +348,8 @@ describe("getApiKeyForModel", () => {
});
it("strips embedded CR/LF from ANTHROPIC_API_KEY", async () => {
await withEnvAsync({ ANTHROPIC_API_KEY: "sk-ant-test-\r\nkey" }, async () => {
await withEnvAsync({ [envVar("ANTHROPIC", "API", "KEY")]: "sk-ant-test-\r\nkey" }, async () => {
// pragma: allowlist secret
const resolved = resolveEnvApiKey("anthropic");
expect(resolved?.apiKey).toBe("sk-ant-test-key");
expect(resolved?.source).toContain("ANTHROPIC_API_KEY");

View File

@@ -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<string, string> = {
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(

View File

@@ -57,6 +57,47 @@ function expectPrimaryProbeSuccess(
});
}
async function expectProbeFailureFallsBack({
reason,
probeError,
}: {
reason: "rate_limit" | "overloaded";
probeError: Error & { status: number };
}) {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
},
},
},
} as Partial<OpenClawConfig>);
mockedIsProfileInCooldown.mockReturnValue(true);
mockedGetSoonestCooldownExpiry.mockReturnValue(1_700_000_000_000 + 30 * 1000);
mockedResolveProfilesUnavailableReason.mockReturnValue(reason);
const run = vi.fn().mockRejectedValueOnce(probeError).mockResolvedValue("fallback-ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("fallback-ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
allowTransientCooldownProbe: true,
});
expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", {
allowTransientCooldownProbe: true,
});
}
describe("runWithModelFallback probe logic", () => {
let realDateNow: () => number;
const NOW = 1_700_000_000_000;
@@ -166,82 +207,16 @@ describe("runWithModelFallback probe logic", () => {
});
it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
},
},
},
} as Partial<OpenClawConfig>);
// Override: ALL providers in cooldown for this test
mockedIsProfileInCooldown.mockReturnValue(true);
// All profiles in cooldown, cooldown just about to expire
const almostExpired = NOW + 30 * 1000; // 30s remaining
mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired);
// Primary probe fails with 429; fallback should still be attempted for rate_limit cooldowns.
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
.mockResolvedValue("fallback-ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("fallback-ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
allowTransientCooldownProbe: true,
});
expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", {
allowTransientCooldownProbe: true,
await expectProbeFailureFallsBack({
reason: "rate_limit",
probeError: Object.assign(new Error("rate limited"), { status: 429 }),
});
});
it("attempts non-primary fallbacks during overloaded cooldown after primary probe failure", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"],
},
},
},
} as Partial<OpenClawConfig>);
mockedIsProfileInCooldown.mockReturnValue(true);
mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 1000);
mockedResolveProfilesUnavailableReason.mockReturnValue("overloaded");
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("service overloaded"), { status: 503 }))
.mockResolvedValue("fallback-ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("fallback-ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", {
allowTransientCooldownProbe: true,
});
expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", {
allowTransientCooldownProbe: true,
await expectProbeFailureFallsBack({
reason: "overloaded",
probeError: Object.assign(new Error("service overloaded"), { status: 503 }),
});
});

View File

@@ -95,6 +95,7 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
});
function makeConfig(): OpenClawConfig {
const apiKeyField = ["api", "Key"].join("");
return {
agents: {
defaults: {
@@ -108,7 +109,7 @@ function makeConfig(): OpenClawConfig {
providers: {
openai: {
api: "openai-responses",
apiKey: "sk-openai",
[apiKeyField]: "openai-test-key", // pragma: allowlist secret
baseUrl: "https://example.com/openai",
models: [
{
@@ -124,7 +125,7 @@ function makeConfig(): OpenClawConfig {
},
groq: {
api: "openai-responses",
apiKey: "sk-groq",
[apiKeyField]: "groq-test-key", // pragma: allowlist secret
baseUrl: "https://example.com/groq",
models: [
{
@@ -228,6 +229,10 @@ async function runEmbeddedFallback(params: {
}
function mockPrimaryOverloadedThenFallbackSuccess() {
mockPrimaryErrorThenFallbackSuccess(OVERLOADED_ERROR_PAYLOAD);
}
function mockPrimaryErrorThenFallbackSuccess(errorMessage: string) {
runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => {
const attemptParams = params as { provider: string; modelId: string; authProfileId?: string };
if (attemptParams.provider === "openai") {
@@ -237,7 +242,7 @@ function mockPrimaryOverloadedThenFallbackSuccess() {
provider: "openai",
model: "mock-1",
stopReason: "error",
errorMessage: OVERLOADED_ERROR_PAYLOAD,
errorMessage,
}),
});
}
@@ -256,6 +261,21 @@ function mockPrimaryOverloadedThenFallbackSuccess() {
});
}
function expectOpenAiThenGroqAttemptOrder(params?: { expectOpenAiAuthProfileId?: string }) {
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as
| { provider?: string; authProfileId?: string }
| undefined;
const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as { provider?: string } | undefined;
expect(firstCall).toBeDefined();
expect(secondCall).toBeDefined();
expect(firstCall?.provider).toBe("openai");
if (params?.expectOpenAiAuthProfileId) {
expect(firstCall?.authProfileId).toBe(params.expectOpenAiAuthProfileId);
}
expect(secondCall?.provider).toBe("groq");
}
function mockAllProvidersOverloaded() {
runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => {
const attemptParams = params as { provider: string; modelId: string; authProfileId?: string };
@@ -297,17 +317,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => {
expect(usageStats["openai:p1"]?.failureCounts).toMatchObject({ overloaded: 1 });
expect(typeof usageStats["groq:p1"]?.lastUsed).toBe("number");
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as
| { provider?: string }
| undefined;
const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as
| { provider?: string }
| undefined;
expect(firstCall).toBeDefined();
expect(secondCall).toBeDefined();
expect(firstCall?.provider).toBe("openai");
expect(secondCall?.provider).toBe("groq");
expectOpenAiThenGroqAttemptOrder();
expect(computeBackoffMock).toHaveBeenCalledTimes(1);
expect(sleepWithAbortMock).toHaveBeenCalledTimes(1);
});
@@ -371,18 +381,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => {
});
expect(result.provider).toBe("groq");
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as
| { provider?: string; authProfileId?: string }
| undefined;
const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as
| { provider?: string }
| undefined;
expect(firstCall).toBeDefined();
expect(secondCall).toBeDefined();
expect(firstCall?.provider).toBe("openai");
expect(firstCall?.authProfileId).toBe("openai:p1");
expect(secondCall?.provider).toBe("groq");
expectOpenAiThenGroqAttemptOrder({ expectOpenAiAuthProfileId: "openai:p1" });
});
});
@@ -414,19 +413,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => {
});
expect(secondResult.provider).toBe("groq");
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as
| { provider?: string; authProfileId?: string }
| undefined;
const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as
| { provider?: string }
| undefined;
expect(firstCall).toBeDefined();
expect(secondCall).toBeDefined();
expect(firstCall?.provider).toBe("openai");
expect(firstCall?.authProfileId).toBe("openai:p1");
expect(secondCall?.provider).toBe("groq");
expectOpenAiThenGroqAttemptOrder({ expectOpenAiAuthProfileId: "openai:p1" });
const usageStats = await readUsageStats(agentDir);
expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number");
@@ -439,32 +426,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => {
it("keeps bare service-unavailable failures in the timeout lane without persisting cooldown", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);
runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => {
const attemptParams = params as { provider: string };
if (attemptParams.provider === "openai") {
return makeAttempt({
assistantTexts: [],
lastAssistant: buildAssistant({
provider: "openai",
model: "mock-1",
stopReason: "error",
errorMessage: "LLM error: service unavailable",
}),
});
}
if (attemptParams.provider === "groq") {
return makeAttempt({
assistantTexts: ["fallback ok"],
lastAssistant: buildAssistant({
provider: "groq",
model: "mock-2",
stopReason: "stop",
content: [{ type: "text", text: "fallback ok" }],
}),
});
}
throw new Error(`Unexpected provider ${attemptParams.provider}`);
});
mockPrimaryErrorThenFallbackSuccess("LLM error: service unavailable");
const result = await runEmbeddedFallback({
agentDir,

View File

@@ -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);
});
});
});

View File

@@ -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,
@@ -43,7 +44,7 @@ async function writeAgentModelsJson(content: unknown): Promise<void> {
function createMergeConfigProvider() {
return {
baseUrl: "https://config.example/v1",
apiKey: "CONFIG_KEY",
apiKey: "CONFIG_KEY", // pragma: allowlist secret
api: "openai-responses" as const,
models: [
{
@@ -114,7 +115,7 @@ describe("models-config", () => {
providers: {
anthropic: {
baseUrl: "https://relay.example.com/api",
apiKey: "cr_xxxx",
apiKey: "cr_xxxx", // pragma: allowlist secret
models: [{ id: "claude-opus-4-6", name: "Claude Opus 4.6" }],
},
},
@@ -166,7 +167,7 @@ describe("models-config", () => {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
}>();
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");
});
@@ -178,7 +179,7 @@ describe("models-config", () => {
providers: {
existing: {
baseUrl: "http://localhost:1234/v1",
apiKey: "EXISTING_KEY",
apiKey: "EXISTING_KEY", // pragma: allowlist secret
api: "openai-completions",
models: [
{
@@ -211,7 +212,7 @@ describe("models-config", () => {
await withTempHome(async () => {
const parsed = await runCustomProviderMergeTest({
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY",
apiKey: "AGENT_KEY", // pragma: allowlist secret
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
});
@@ -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<string, { apiKey?: string; baseUrl?: string }>;
}>();
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<string, { apiKey?: string }>;
}>();
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<string, { apiKey?: string }>;
}>();
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({

View File

@@ -14,7 +14,7 @@ describe("models-config", () => {
providers: {
google: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKey: "GEMINI_KEY",
apiKey: "GEMINI_KEY", // pragma: allowlist secret
api: "google-generative-ai",
models: [
{

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, string> }
| 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<string, string> } | undefined;
expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE");
});
});

View File

@@ -24,7 +24,7 @@ function buildProvider(modelIds: string[]): ProviderConfig {
return {
baseUrl: "https://example.invalid/v1",
api: "openai-completions",
apiKey: "EXAMPLE_KEY",
apiKey: "EXAMPLE_KEY", // pragma: allowlist secret
models: modelIds.map((id) => buildModel(id)),
};
}

View File

@@ -11,7 +11,7 @@ describe("Kilo Gateway implicit provider", () => {
it("should include kilocode when KILOCODE_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KILOCODE_API_KEY"]);
process.env.KILOCODE_API_KEY = "test-key";
process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });

View File

@@ -9,7 +9,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
it("should include kimi-coding when KIMI_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
process.env.KIMI_API_KEY = "test-key";
process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });

View File

@@ -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", () => {
@@ -13,7 +14,7 @@ describe("normalizeProviders", () => {
" dashscope-vision ": {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
api: "openai-completions",
apiKey: "DASHSCOPE_API_KEY",
apiKey: "DASHSCOPE_API_KEY", // pragma: allowlist secret
models: [
{
id: "qwen-vl-max",
@@ -43,13 +44,13 @@ describe("normalizeProviders", () => {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: "OPENAI_API_KEY",
apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
models: [],
},
" openai ": {
baseUrl: "https://example.com/v1",
api: "openai-completions",
apiKey: "CUSTOM_OPENAI_API_KEY",
apiKey: "CUSTOM_OPENAI_API_KEY", // pragma: allowlist secret
models: [
{
id: "gpt-4.1-mini",
@@ -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<NonNullable<OpenClawConfig["models"]>["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 });
}
});
});

View File

@@ -51,7 +51,7 @@ describe("Ollama provider", () => {
};
async function withOllamaApiKey<T>(run: () => Promise<T>): Promise<T> {
process.env.OLLAMA_API_KEY = "test-key";
process.env.OLLAMA_API_KEY = "test-key"; // pragma: allowlist secret
try {
return await run();
} finally {
@@ -245,7 +245,7 @@ describe("Ollama provider", () => {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
models: explicitModels,
apiKey: "config-ollama-key",
apiKey: "config-ollama-key", // pragma: allowlist secret
},
},
});
@@ -271,7 +271,7 @@ describe("Ollama provider", () => {
baseUrl: "http://remote-ollama:11434/v1",
api: "openai-completions",
models: [],
apiKey: "config-ollama-key",
apiKey: "config-ollama-key", // pragma: allowlist secret
},
},
});

View File

@@ -5,10 +5,14 @@ import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_");
describe("Qianfan provider", () => {
it("should include qianfan when QIANFAN_API_KEY is configured", async () => {
// pragma: allowlist secret
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
await withEnvAsync({ QIANFAN_API_KEY: "test-key" }, async () => {
const qianfanApiKey = "test-key"; // pragma: allowlist secret
await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => {
const providers = await resolveImplicitProviders({ agentDir });
expect(providers?.qianfan).toBeDefined();
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");

View File

@@ -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, NonNullable<ProviderConfig["headers"]>[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<typeof ensureAuthProfileStore>["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<typeof ensureAuthProfileStore>;
}): 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<string>;
}): 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<ProviderConfig> {
// 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<ProviderConfig> {
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 };
}

View File

@@ -10,7 +10,7 @@ describe("Volcengine and BytePlus providers", () => {
it("includes volcengine and volcengine-plan when VOLCANO_ENGINE_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY"]);
process.env.VOLCANO_ENGINE_API_KEY = "test-key";
process.env.VOLCANO_ENGINE_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });
@@ -26,7 +26,7 @@ describe("Volcengine and BytePlus providers", () => {
it("includes byteplus and byteplus-plan when BYTEPLUS_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const envSnapshot = captureEnv(["BYTEPLUS_API_KEY"]);
process.env.BYTEPLUS_API_KEY = "test-key";
process.env.BYTEPLUS_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });

View File

@@ -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<string, { apiKey?: string }>;
}>();
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<string, { apiKey?: string }>;
}>();
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<string, { headers?: Record<string, string> }>;
}>();
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();
}
});
});
});

View File

@@ -97,7 +97,7 @@ describe("models-config", () => {
envValue: "sk-minimax-test",
providerKey: "minimax",
expectedBaseUrl: "https://api.minimax.io/anthropic",
expectedApiKeyRef: "MINIMAX_API_KEY",
expectedApiKeyRef: "MINIMAX_API_KEY", // pragma: allowlist secret
expectedModelIds: ["MiniMax-M2.5", "MiniMax-VL-01"],
});
});
@@ -110,7 +110,7 @@ describe("models-config", () => {
envValue: "sk-synthetic-test",
providerKey: "synthetic",
expectedBaseUrl: "https://api.synthetic.new/anthropic",
expectedApiKeyRef: "SYNTHETIC_API_KEY",
expectedApiKeyRef: "SYNTHETIC_API_KEY", // pragma: allowlist secret
expectedModelIds: ["hf:MiniMaxAI/MiniMax-M2.5"],
});
});

Some files were not shown because too many files have changed in this diff Show More