mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-29 16:54:30 +00:00
Merge branch 'main' into vincentkoc-code/issue-28140-invalid-config-fail-closed
This commit is contained in:
@@ -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\.\.\.",
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
2370
.secrets.baseline
2370
.secrets.baseline
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -55,7 +55,7 @@ class AppUpdateHandlerTest {
|
||||
try {
|
||||
tmp.writeText("hello", Charsets.UTF_8)
|
||||
assertEquals(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret
|
||||
sha256Hex(tmp),
|
||||
)
|
||||
} finally {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -51,7 +51,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
- Workspace default (or existing workspace)
|
||||
- Gateway port **18789**
|
||||
- Gateway auth **Token** (auto‑generated, even on loopback)
|
||||
- Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved)
|
||||
- Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved)
|
||||
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
|
||||
- Tailscale exposure **Off**
|
||||
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
90
extensions/feishu/src/docx-batch-insert.test.ts
Normal file
90
extensions/feishu/src/docx-batch-insert.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("resolveMSTeamsCredentials", () => {
|
||||
|
||||
expect(resolved).toEqual({
|
||||
appId: "app-id",
|
||||
appPassword: "app-password",
|
||||
appPassword: "app-password", // pragma: allowlist secret
|
||||
tenantId: "tenant-id",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
42
src/agents/model-auth-env-vars.ts
Normal file
42
src/agents/model-auth-env-vars.ts
Normal 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())];
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
26
src/agents/model-auth-markers.test.ts
Normal file
26
src/agents/model-auth-markers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
src/agents/model-auth-markers.ts
Normal file
80
src/agents/model-auth-markers.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
src/agents/models-config.file-mode.test.ts
Normal file
43
src/agents/models-config.file-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
121
src/agents/models-config.providers.auth-provenance.test.ts
Normal file
121
src/agents/models-config.providers.auth-provenance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
140
src/agents/models-config.providers.discovery-auth.test.ts
Normal file
140
src/agents/models-config.providers.discovery-auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
162
src/agents/models-config.runtime-source-snapshot.test.ts
Normal file
162
src/agents/models-config.runtime-source-snapshot.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user