Merge branch 'main' into vincentkoc-code/security-hardening-auth-timeouts
This commit is contained in:
commit
9761a71a5a
@ -9777,35 +9777,35 @@
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
||||
"is_verified": false,
|
||||
"line_number": 2039
|
||||
"line_number": 2041
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 2271
|
||||
"line_number": 2273
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
||||
"is_verified": false,
|
||||
"line_number": 2399
|
||||
"line_number": 2401
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
||||
"is_verified": false,
|
||||
"line_number": 2652
|
||||
"line_number": 2654
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
|
||||
"is_verified": false,
|
||||
"line_number": 2654
|
||||
"line_number": 2656
|
||||
}
|
||||
],
|
||||
"docs/gateway/configuration.md": [
|
||||
@ -12076,7 +12076,7 @@
|
||||
"filename": "src/agents/model-auth.ts",
|
||||
"hashed_secret": "8956265d216d474a080edaa97880d37fc1386f33",
|
||||
"is_verified": false,
|
||||
"line_number": 25
|
||||
"line_number": 27
|
||||
}
|
||||
],
|
||||
"src/agents/model-fallback.run-embedded.e2e.test.ts": [
|
||||
@ -12110,28 +12110,28 @@
|
||||
"filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts",
|
||||
"hashed_secret": "2a9da819718779deba96d5aee1d1f4948047c2bd",
|
||||
"is_verified": false,
|
||||
"line_number": 46
|
||||
"line_number": 47
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts",
|
||||
"hashed_secret": "fa9144b340ea7886885669e2e7a808c86ee14a07",
|
||||
"is_verified": false,
|
||||
"line_number": 117
|
||||
"line_number": 118
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts",
|
||||
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
|
||||
"is_verified": false,
|
||||
"line_number": 181
|
||||
"line_number": 182
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts",
|
||||
"hashed_secret": "565a8d87240aae631d7a901c1f697d46ee141a7b",
|
||||
"is_verified": false,
|
||||
"line_number": 214
|
||||
"line_number": 215
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": [
|
||||
@ -12158,7 +12158,7 @@
|
||||
"filename": "src/agents/models-config.providers.kilocode.test.ts",
|
||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
||||
"is_verified": false,
|
||||
"line_number": 24
|
||||
"line_number": 14
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.providers.kimi-coding.test.ts": [
|
||||
@ -12176,21 +12176,21 @@
|
||||
"filename": "src/agents/models-config.providers.normalize-keys.test.ts",
|
||||
"hashed_secret": "ba4d38e2a7e8c718913887136d2526351d05cd69",
|
||||
"is_verified": false,
|
||||
"line_number": 16
|
||||
"line_number": 17
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.providers.normalize-keys.test.ts",
|
||||
"hashed_secret": "02ecb94373bfb3dfe827ca18409f50b016e8302a",
|
||||
"is_verified": false,
|
||||
"line_number": 46
|
||||
"line_number": 47
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/models-config.providers.normalize-keys.test.ts",
|
||||
"hashed_secret": "b9cdfe69a75e4f2491bcbaf1934ab5e4fd69eb6b",
|
||||
"is_verified": false,
|
||||
"line_number": 52
|
||||
"line_number": 53
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.providers.nvidia.test.ts": [
|
||||
@ -12324,7 +12324,7 @@
|
||||
"filename": "src/agents/pi-embedded-runner/model.ts",
|
||||
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
|
||||
"is_verified": false,
|
||||
"line_number": 232
|
||||
"line_number": 267
|
||||
}
|
||||
],
|
||||
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
|
||||
@ -12342,21 +12342,21 @@
|
||||
"filename": "src/agents/pi-extensions/compaction-safeguard.test.ts",
|
||||
"hashed_secret": "0091061a3babbe6f11d48aa0142e22341b3ea446",
|
||||
"is_verified": false,
|
||||
"line_number": 665
|
||||
"line_number": 700
|
||||
},
|
||||
{
|
||||
"type": "Hex High Entropy String",
|
||||
"filename": "src/agents/pi-extensions/compaction-safeguard.test.ts",
|
||||
"hashed_secret": "ef678205593788329ff416ce5c65fa04f33a05bd",
|
||||
"is_verified": false,
|
||||
"line_number": 811
|
||||
"line_number": 846
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/agents/pi-extensions/compaction-safeguard.test.ts",
|
||||
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
|
||||
"is_verified": false,
|
||||
"line_number": 1490
|
||||
"line_number": 1525
|
||||
}
|
||||
],
|
||||
"src/agents/sandbox/browser.novnc-url.test.ts": [
|
||||
@ -13008,7 +13008,7 @@
|
||||
"filename": "src/commands/message.test.ts",
|
||||
"hashed_secret": "3bb1ec510d35ab2af7d05d8bbd5f0820333f1a0d",
|
||||
"is_verified": false,
|
||||
"line_number": 194
|
||||
"line_number": 193
|
||||
}
|
||||
],
|
||||
"src/commands/model-picker.test.ts": [
|
||||
@ -13026,14 +13026,14 @@
|
||||
"filename": "src/commands/onboard-auth.config-core.kilocode.test.ts",
|
||||
"hashed_secret": "01800a0712a2a1aa928b95c4745e9ee06673925b",
|
||||
"is_verified": false,
|
||||
"line_number": 163
|
||||
"line_number": 153
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/commands/onboard-auth.config-core.kilocode.test.ts",
|
||||
"hashed_secret": "8d2ce71c6723bf46f6c166984b4ddb597f92322a",
|
||||
"is_verified": false,
|
||||
"line_number": 190
|
||||
"line_number": 180
|
||||
}
|
||||
],
|
||||
"src/commands/onboard-auth.config-minimax.ts": [
|
||||
@ -14036,21 +14036,21 @@
|
||||
"filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts",
|
||||
"hashed_secret": "45c7365e3b542cdb4fae6ec10c2ff149224d7656",
|
||||
"is_verified": false,
|
||||
"line_number": 123
|
||||
"line_number": 124
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts",
|
||||
"hashed_secret": "b67074884ab7ef7c7a8cd6a3da9565d96c792248",
|
||||
"is_verified": false,
|
||||
"line_number": 124
|
||||
"line_number": 125
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts",
|
||||
"hashed_secret": "d4d8027e64f9cf4180d3aecfe31ea409368022ee",
|
||||
"is_verified": false,
|
||||
"line_number": 125
|
||||
"line_number": 126
|
||||
}
|
||||
],
|
||||
"src/infra/push-apns.test.ts": [
|
||||
@ -14114,14 +14114,14 @@
|
||||
"filename": "src/line/bot-handlers.test.ts",
|
||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
||||
"is_verified": false,
|
||||
"line_number": 107
|
||||
"line_number": 101
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/line/bot-handlers.test.ts",
|
||||
"hashed_secret": "d76baddf1b9e3d8e31216f22c73d65d2e91ada7b",
|
||||
"is_verified": false,
|
||||
"line_number": 358
|
||||
"line_number": 399
|
||||
}
|
||||
],
|
||||
"src/line/bot-message-context.test.ts": [
|
||||
@ -14408,28 +14408,28 @@
|
||||
"filename": "src/secrets/apply.test.ts",
|
||||
"hashed_secret": "bb0a04dd3612988998c812bc3ad580ba0fb9d905",
|
||||
"is_verified": false,
|
||||
"line_number": 360
|
||||
"line_number": 372
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/secrets/apply.test.ts",
|
||||
"hashed_secret": "942c7142a36b069509b957db07321a1cb9b2123a",
|
||||
"is_verified": false,
|
||||
"line_number": 397
|
||||
"line_number": 409
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/secrets/apply.test.ts",
|
||||
"hashed_secret": "9c0faa509a7c3079f58421307ecbcaceb7cbd545",
|
||||
"is_verified": false,
|
||||
"line_number": 450
|
||||
"line_number": 503
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/secrets/apply.test.ts",
|
||||
"hashed_secret": "c9a4d024f4386d3a4b044de8cb52226383591481",
|
||||
"is_verified": false,
|
||||
"line_number": 483
|
||||
"line_number": 536
|
||||
}
|
||||
],
|
||||
"src/secrets/command-config.test.ts": [
|
||||
@ -14725,5 +14725,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-07T11:12:54Z"
|
||||
"generated_at": "2026-03-07T17:40:40Z"
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Onboarding/local setup: default unset local `tools.profile` to `coding` instead of `messaging`, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.
|
||||
- Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)
|
||||
- Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.
|
||||
- Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.
|
||||
@ -109,6 +110,7 @@ Docs: https://docs.openclaw.ai
|
||||
- TUI/session-key canonicalization: normalize `openclaw tui --session` values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.
|
||||
- iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.
|
||||
- Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
|
||||
- Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.
|
||||
- Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
|
||||
- Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
|
||||
- ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.
|
||||
@ -239,6 +241,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Config/compaction safeguard settings: regression-test `agents.defaults.compaction.recentTurnsPreserve` through `loadConfig()` and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.
|
||||
- iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.
|
||||
- CLI/Docs memory help accuracy: clarify `openclaw memory status --deep` behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.
|
||||
- Auto-reply/allowlist store account scoping: keep `/allowlist ... --store` writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix.
|
||||
- Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (`x-forwarded-for` / `x-real-ip`) and rejecting `sec-fetch-site: cross-site`; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
@ -597,6 +601,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Models/provider config precedence: prefer exact `models.providers.<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.
|
||||
- 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.
|
||||
|
||||
@ -38,7 +38,7 @@ def maybe_decode_hex_keychain_secret(value)
|
||||
|
||||
# `security find-generic-password -w` can return hex when the stored secret
|
||||
# includes newlines/non-printable bytes (like PEM files).
|
||||
if decoded.include?("BEGIN PRIVATE KEY") || decoded.include?("END PRIVATE KEY")
|
||||
if decoded.include?("BEGIN PRIVATE KEY") || decoded.include?("END PRIVATE KEY") # pragma: allowlist secret
|
||||
UI.message("Decoded hex-encoded ASC key content from Keychain.")
|
||||
return decoded
|
||||
end
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
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())];
|
||||
}
|
||||
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);
|
||||
}
|
||||
@ -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(
|
||||
|
||||
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,
|
||||
@ -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");
|
||||
});
|
||||
@ -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({
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import { normalizeProviders } from "./models-config.providers.js";
|
||||
|
||||
describe("normalizeProviders", () => {
|
||||
@ -73,4 +74,30 @@ describe("normalizeProviders", () => {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes SecretRef-backed provider headers to non-secret marker values", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
|
||||
try {
|
||||
const providers: NonNullable<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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
type OpenClawConfig,
|
||||
loadConfig,
|
||||
} from "../config/config.js";
|
||||
import { applyConfigEnvVars } from "../config/env-vars.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
|
||||
import {
|
||||
normalizeProviders,
|
||||
type ProviderConfig,
|
||||
@ -15,6 +21,7 @@ import {
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
|
||||
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
|
||||
const MODELS_JSON_WRITE_LOCKS = new Map<string, Promise<void>>();
|
||||
|
||||
function resolvePreferredTokenLimit(explicitValue: number, implicitValue: number): number {
|
||||
// Keep catalog refresh behavior for stale low values while preserving
|
||||
@ -141,8 +148,9 @@ async function resolveProvidersForModelsJson(params: {
|
||||
function mergeWithExistingProviderSecrets(params: {
|
||||
nextProviders: Record<string, ProviderConfig>;
|
||||
existingProviders: Record<string, NonNullable<ModelsConfig["providers"]>[string]>;
|
||||
secretRefManagedProviders: ReadonlySet<string>;
|
||||
}): Record<string, ProviderConfig> {
|
||||
const { nextProviders, existingProviders } = params;
|
||||
const { nextProviders, existingProviders, secretRefManagedProviders } = params;
|
||||
const mergedProviders: Record<string, ProviderConfig> = {};
|
||||
for (const [key, entry] of Object.entries(existingProviders)) {
|
||||
mergedProviders[key] = entry;
|
||||
@ -159,7 +167,12 @@ function mergeWithExistingProviderSecrets(params: {
|
||||
continue;
|
||||
}
|
||||
const preserved: Record<string, unknown> = {};
|
||||
if (typeof existing.apiKey === "string" && existing.apiKey) {
|
||||
if (
|
||||
!secretRefManagedProviders.has(key) &&
|
||||
typeof existing.apiKey === "string" &&
|
||||
existing.apiKey &&
|
||||
!isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })
|
||||
) {
|
||||
preserved.apiKey = existing.apiKey;
|
||||
}
|
||||
if (typeof existing.baseUrl === "string" && existing.baseUrl) {
|
||||
@ -174,6 +187,7 @@ async function resolveProvidersForMode(params: {
|
||||
mode: NonNullable<ModelsConfig["mode"]>;
|
||||
targetPath: string;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
secretRefManagedProviders: ReadonlySet<string>;
|
||||
}): Promise<Record<string, ProviderConfig>> {
|
||||
if (params.mode !== "merge") {
|
||||
return params.providers;
|
||||
@ -189,6 +203,7 @@ async function resolveProvidersForMode(params: {
|
||||
return mergeWithExistingProviderSecrets({
|
||||
nextProviders: params.providers,
|
||||
existingProviders,
|
||||
secretRefManagedProviders: params.secretRefManagedProviders,
|
||||
});
|
||||
}
|
||||
|
||||
@ -200,45 +215,94 @@ async function readRawFile(pathname: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureModelsFileMode(pathname: string): Promise<void> {
|
||||
await fs.chmod(pathname, 0o600).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig {
|
||||
const runtimeSource = getRuntimeConfigSourceSnapshot();
|
||||
if (!runtimeSource) {
|
||||
return config ?? loadConfig();
|
||||
}
|
||||
if (!config) {
|
||||
return runtimeSource;
|
||||
}
|
||||
const runtimeResolved = getRuntimeConfigSnapshot();
|
||||
if (runtimeResolved && config === runtimeResolved) {
|
||||
return runtimeSource;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function withModelsJsonWriteLock<T>(targetPath: string, run: () => Promise<T>): Promise<T> {
|
||||
const prior = MODELS_JSON_WRITE_LOCKS.get(targetPath) ?? Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const pending = prior.then(() => gate);
|
||||
MODELS_JSON_WRITE_LOCKS.set(targetPath, pending);
|
||||
try {
|
||||
await prior;
|
||||
return await run();
|
||||
} finally {
|
||||
release();
|
||||
if (MODELS_JSON_WRITE_LOCKS.get(targetPath) === pending) {
|
||||
MODELS_JSON_WRITE_LOCKS.delete(targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureOpenClawModelsJson(
|
||||
config?: OpenClawConfig,
|
||||
agentDirOverride?: string,
|
||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||
const cfg = config ?? loadConfig();
|
||||
const cfg = resolveModelsConfigInput(config);
|
||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
|
||||
|
||||
// Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are
|
||||
// available in process.env before implicit provider discovery. Some
|
||||
// callers (agent runner, tools) pass config objects that haven't gone
|
||||
// through the full loadConfig() pipeline which applies these.
|
||||
applyConfigEnvVars(cfg);
|
||||
|
||||
const providers = await resolveProvidersForModelsJson({ cfg, agentDir });
|
||||
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
const mode = cfg.models?.mode ?? DEFAULT_MODE;
|
||||
const targetPath = path.join(agentDir, "models.json");
|
||||
const mergedProviders = await resolveProvidersForMode({
|
||||
mode,
|
||||
targetPath,
|
||||
providers,
|
||||
|
||||
return await withModelsJsonWriteLock(targetPath, async () => {
|
||||
// Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are
|
||||
// available in process.env before implicit provider discovery. Some
|
||||
// callers (agent runner, tools) pass config objects that haven't gone
|
||||
// through the full loadConfig() pipeline which applies these.
|
||||
applyConfigEnvVars(cfg);
|
||||
|
||||
const providers = await resolveProvidersForModelsJson({ cfg, agentDir });
|
||||
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
const mode = cfg.models?.mode ?? DEFAULT_MODE;
|
||||
const secretRefManagedProviders = new Set<string>();
|
||||
|
||||
const normalizedProviders =
|
||||
normalizeProviders({
|
||||
providers,
|
||||
agentDir,
|
||||
secretDefaults: cfg.secrets?.defaults,
|
||||
secretRefManagedProviders,
|
||||
}) ?? providers;
|
||||
const mergedProviders = await resolveProvidersForMode({
|
||||
mode,
|
||||
targetPath,
|
||||
providers: normalizedProviders,
|
||||
secretRefManagedProviders,
|
||||
});
|
||||
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
|
||||
const existingRaw = await readRawFile(targetPath);
|
||||
|
||||
if (existingRaw === next) {
|
||||
await ensureModelsFileMode(targetPath);
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(targetPath, next, { mode: 0o600 });
|
||||
await ensureModelsFileMode(targetPath);
|
||||
return { agentDir, wrote: true };
|
||||
});
|
||||
|
||||
const normalizedProviders = normalizeProviders({
|
||||
providers: mergedProviders,
|
||||
agentDir,
|
||||
});
|
||||
const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`;
|
||||
const existingRaw = await readRawFile(targetPath);
|
||||
|
||||
if (existingRaw === next) {
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(targetPath, next, { mode: 0o600 });
|
||||
return { agentDir, wrote: true };
|
||||
}
|
||||
|
||||
55
src/agents/models-config.write-serialization.test.ts
Normal file
55
src/agents/models-config.write-serialization.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CUSTOM_PROXY_MODELS_CONFIG,
|
||||
installModelsConfigTestHooks,
|
||||
withModelsTempHome,
|
||||
} from "./models-config.e2e-harness.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
|
||||
describe("models-config write serialization", () => {
|
||||
it("serializes concurrent models.json writes to avoid overlap", async () => {
|
||||
await withModelsTempHome(async () => {
|
||||
const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);
|
||||
const firstModel = first.models?.providers?.["custom-proxy"]?.models?.[0];
|
||||
const secondModel = second.models?.providers?.["custom-proxy"]?.models?.[0];
|
||||
if (!firstModel || !secondModel) {
|
||||
throw new Error("custom-proxy fixture missing expected model entries");
|
||||
}
|
||||
firstModel.name = "Proxy A";
|
||||
secondModel.name = "Proxy B with longer name";
|
||||
|
||||
const originalWriteFile = fs.writeFile.bind(fs);
|
||||
let inFlightWrites = 0;
|
||||
let maxInFlightWrites = 0;
|
||||
const writeSpy = vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => {
|
||||
inFlightWrites += 1;
|
||||
if (inFlightWrites > maxInFlightWrites) {
|
||||
maxInFlightWrites = inFlightWrites;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
try {
|
||||
return await originalWriteFile(...args);
|
||||
} finally {
|
||||
inFlightWrites -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([ensureOpenClawModelsJson(first), ensureOpenClawModelsJson(second)]);
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(maxInFlightWrites).toBe(1);
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: { "custom-proxy"?: { models?: Array<{ name?: string }> } };
|
||||
}>();
|
||||
expect(parsed.providers["custom-proxy"]?.models?.[0]?.name).toBe("Proxy B with longer name");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as fences from "../markdown/fences.js";
|
||||
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||
|
||||
function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) {
|
||||
@ -120,4 +121,20 @@ describe("EmbeddedBlockChunker", () => {
|
||||
expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]);
|
||||
expect(chunker.bufferedText).toBe("After fence");
|
||||
});
|
||||
|
||||
it("parses fence spans once per drain call for long fenced buffers", () => {
|
||||
const parseSpy = vi.spyOn(fences, "parseFenceSpans");
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
minChars: 20,
|
||||
maxChars: 80,
|
||||
breakPreference: "paragraph",
|
||||
});
|
||||
|
||||
chunker.append(`\`\`\`txt\n${"line\n".repeat(600)}\`\`\``);
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(2);
|
||||
expect(parseSpy).toHaveBeenCalledTimes(1);
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ export type BlockReplyChunking = {
|
||||
type FenceSplit = {
|
||||
closeFenceLine: string;
|
||||
reopenFenceLine: string;
|
||||
fence: FenceSpan;
|
||||
};
|
||||
|
||||
type BreakResult = {
|
||||
@ -28,6 +29,7 @@ function findSafeSentenceBreakIndex(
|
||||
text: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
minChars: number,
|
||||
offset = 0,
|
||||
): number {
|
||||
const matches = text.matchAll(/[.!?](?=\s|$)/g);
|
||||
let sentenceIdx = -1;
|
||||
@ -37,7 +39,7 @@ function findSafeSentenceBreakIndex(
|
||||
continue;
|
||||
}
|
||||
const candidate = at + 1;
|
||||
if (isSafeFenceBreak(fenceSpans, candidate)) {
|
||||
if (isSafeFenceBreak(fenceSpans, offset + candidate)) {
|
||||
sentenceIdx = candidate;
|
||||
}
|
||||
}
|
||||
@ -49,8 +51,9 @@ function findSafeParagraphBreakIndex(params: {
|
||||
fenceSpans: FenceSpan[];
|
||||
minChars: number;
|
||||
reverse: boolean;
|
||||
offset?: number;
|
||||
}): number {
|
||||
const { text, fenceSpans, minChars, reverse } = params;
|
||||
const { text, fenceSpans, minChars, reverse, offset = 0 } = params;
|
||||
let paragraphIdx = reverse ? text.lastIndexOf("\n\n") : text.indexOf("\n\n");
|
||||
while (reverse ? paragraphIdx >= minChars : paragraphIdx !== -1) {
|
||||
const candidates = [paragraphIdx, paragraphIdx + 1];
|
||||
@ -61,7 +64,7 @@ function findSafeParagraphBreakIndex(params: {
|
||||
if (candidate < 0 || candidate >= text.length) {
|
||||
continue;
|
||||
}
|
||||
if (isSafeFenceBreak(fenceSpans, candidate)) {
|
||||
if (isSafeFenceBreak(fenceSpans, offset + candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
@ -77,11 +80,12 @@ function findSafeNewlineBreakIndex(params: {
|
||||
fenceSpans: FenceSpan[];
|
||||
minChars: number;
|
||||
reverse: boolean;
|
||||
offset?: number;
|
||||
}): number {
|
||||
const { text, fenceSpans, minChars, reverse } = params;
|
||||
const { text, fenceSpans, minChars, reverse, offset = 0 } = params;
|
||||
let newlineIdx = reverse ? text.lastIndexOf("\n") : text.indexOf("\n");
|
||||
while (reverse ? newlineIdx >= minChars : newlineIdx !== -1) {
|
||||
if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, newlineIdx)) {
|
||||
if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, offset + newlineIdx)) {
|
||||
return newlineIdx;
|
||||
}
|
||||
newlineIdx = reverse
|
||||
@ -125,14 +129,7 @@ export class EmbeddedBlockChunker {
|
||||
const minChars = Math.max(1, Math.floor(this.#chunking.minChars));
|
||||
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
|
||||
|
||||
// When flushOnParagraph is set (chunkMode="newline"), eagerly split on \n\n
|
||||
// boundaries regardless of minChars so each paragraph is sent immediately.
|
||||
if (this.#chunking.flushOnParagraph && !force) {
|
||||
this.#drainParagraphs(emit, maxChars);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#buffer.length < minChars && !force) {
|
||||
if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -144,108 +141,132 @@ export class EmbeddedBlockChunker {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.#buffer.length >= minChars || (force && this.#buffer.length > 0)) {
|
||||
const source = this.#buffer;
|
||||
const fenceSpans = parseFenceSpans(source);
|
||||
let start = 0;
|
||||
let reopenFence: FenceSpan | undefined;
|
||||
|
||||
while (start < source.length) {
|
||||
const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : "";
|
||||
const remainingLength = reopenPrefix.length + (source.length - start);
|
||||
|
||||
if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.#chunking.flushOnParagraph && !force) {
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start);
|
||||
const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length);
|
||||
if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) {
|
||||
const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`;
|
||||
if (chunk.trim().length > 0) {
|
||||
emit(chunk);
|
||||
}
|
||||
start = skipLeadingNewlines(source, paragraphBreak.index + paragraphBreak.length);
|
||||
reopenFence = undefined;
|
||||
continue;
|
||||
}
|
||||
if (remainingLength < maxChars) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const view = source.slice(start);
|
||||
const breakResult =
|
||||
force && this.#buffer.length <= maxChars
|
||||
? this.#pickSoftBreakIndex(this.#buffer, 1)
|
||||
: this.#pickBreakIndex(this.#buffer, force ? 1 : undefined);
|
||||
force && remainingLength <= maxChars
|
||||
? this.#pickSoftBreakIndex(view, fenceSpans, 1, start)
|
||||
: this.#pickBreakIndex(
|
||||
view,
|
||||
fenceSpans,
|
||||
force || this.#chunking.flushOnParagraph ? 1 : undefined,
|
||||
start,
|
||||
);
|
||||
if (breakResult.index <= 0) {
|
||||
if (force) {
|
||||
emit(this.#buffer);
|
||||
this.#buffer = "";
|
||||
emit(`${reopenPrefix}${source.slice(start)}`);
|
||||
start = source.length;
|
||||
reopenFence = undefined;
|
||||
}
|
||||
return;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!this.#emitBreakResult(breakResult, emit)) {
|
||||
const consumed = this.#emitBreakResult({
|
||||
breakResult,
|
||||
emit,
|
||||
reopenPrefix,
|
||||
source,
|
||||
start,
|
||||
});
|
||||
if (consumed === null) {
|
||||
continue;
|
||||
}
|
||||
start = consumed.start;
|
||||
reopenFence = consumed.reopenFence;
|
||||
|
||||
if (this.#buffer.length < minChars && !force) {
|
||||
return;
|
||||
const nextLength =
|
||||
(reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start);
|
||||
if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
break;
|
||||
}
|
||||
if (this.#buffer.length < maxChars && !force) {
|
||||
return;
|
||||
if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.#buffer = reopenFence
|
||||
? `${reopenFence.openLine}\n${source.slice(start)}`
|
||||
: stripLeadingNewlines(source.slice(start));
|
||||
}
|
||||
|
||||
/** Eagerly emit complete paragraphs (text before \n\n) regardless of minChars. */
|
||||
#drainParagraphs(emit: (chunk: string) => void, maxChars: number) {
|
||||
while (this.#buffer.length > 0) {
|
||||
const fenceSpans = parseFenceSpans(this.#buffer);
|
||||
const paragraphBreak = findNextParagraphBreak(this.#buffer, fenceSpans);
|
||||
if (!paragraphBreak || paragraphBreak.index > maxChars) {
|
||||
// No paragraph boundary yet (or the next boundary is too far). If the
|
||||
// buffer exceeds maxChars, fall back to normal break logic to avoid
|
||||
// oversized chunks or unbounded accumulation.
|
||||
if (this.#buffer.length >= maxChars) {
|
||||
const breakResult = this.#pickBreakIndex(this.#buffer, 1);
|
||||
if (breakResult.index > 0) {
|
||||
this.#emitBreakResult(breakResult, emit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = this.#buffer.slice(0, paragraphBreak.index);
|
||||
if (chunk.trim().length > 0) {
|
||||
emit(chunk);
|
||||
}
|
||||
this.#buffer = stripLeadingNewlines(
|
||||
this.#buffer.slice(paragraphBreak.index + paragraphBreak.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#emitBreakResult(breakResult: BreakResult, emit: (chunk: string) => void): boolean {
|
||||
#emitBreakResult(params: {
|
||||
breakResult: BreakResult;
|
||||
emit: (chunk: string) => void;
|
||||
reopenPrefix: string;
|
||||
source: string;
|
||||
start: number;
|
||||
}): { start: number; reopenFence?: FenceSpan } | null {
|
||||
const { breakResult, emit, reopenPrefix, source, start } = params;
|
||||
const breakIdx = breakResult.index;
|
||||
if (breakIdx <= 0) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
let rawChunk = this.#buffer.slice(0, breakIdx);
|
||||
const absoluteBreakIdx = start + breakIdx;
|
||||
let rawChunk = `${reopenPrefix}${source.slice(start, absoluteBreakIdx)}`;
|
||||
if (rawChunk.trim().length === 0) {
|
||||
this.#buffer = stripLeadingNewlines(this.#buffer.slice(breakIdx)).trimStart();
|
||||
return false;
|
||||
return { start: skipLeadingNewlines(source, absoluteBreakIdx), reopenFence: undefined };
|
||||
}
|
||||
|
||||
let nextBuffer = this.#buffer.slice(breakIdx);
|
||||
const fenceSplit = breakResult.fenceSplit;
|
||||
if (fenceSplit) {
|
||||
const closeFence = rawChunk.endsWith("\n")
|
||||
? `${fenceSplit.closeFenceLine}\n`
|
||||
: `\n${fenceSplit.closeFenceLine}\n`;
|
||||
rawChunk = `${rawChunk}${closeFence}`;
|
||||
|
||||
const reopenFence = fenceSplit.reopenFenceLine.endsWith("\n")
|
||||
? fenceSplit.reopenFenceLine
|
||||
: `${fenceSplit.reopenFenceLine}\n`;
|
||||
nextBuffer = `${reopenFence}${nextBuffer}`;
|
||||
}
|
||||
|
||||
emit(rawChunk);
|
||||
|
||||
if (fenceSplit) {
|
||||
this.#buffer = nextBuffer;
|
||||
} else {
|
||||
const nextStart =
|
||||
breakIdx < this.#buffer.length && /\s/.test(this.#buffer[breakIdx])
|
||||
? breakIdx + 1
|
||||
: breakIdx;
|
||||
this.#buffer = stripLeadingNewlines(this.#buffer.slice(nextStart));
|
||||
return { start: absoluteBreakIdx, reopenFence: fenceSplit.fence };
|
||||
}
|
||||
|
||||
return true;
|
||||
const nextStart =
|
||||
absoluteBreakIdx < source.length && /\s/.test(source[absoluteBreakIdx])
|
||||
? absoluteBreakIdx + 1
|
||||
: absoluteBreakIdx;
|
||||
return { start: skipLeadingNewlines(source, nextStart), reopenFence: undefined };
|
||||
}
|
||||
|
||||
#pickSoftBreakIndex(buffer: string, minCharsOverride?: number): BreakResult {
|
||||
#pickSoftBreakIndex(
|
||||
buffer: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
minCharsOverride?: number,
|
||||
offset = 0,
|
||||
): BreakResult {
|
||||
const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars));
|
||||
if (buffer.length < minChars) {
|
||||
return { index: -1 };
|
||||
}
|
||||
const fenceSpans = parseFenceSpans(buffer);
|
||||
const preference = this.#chunking.breakPreference ?? "paragraph";
|
||||
|
||||
if (preference === "paragraph") {
|
||||
@ -254,6 +275,7 @@ export class EmbeddedBlockChunker {
|
||||
fenceSpans,
|
||||
minChars,
|
||||
reverse: false,
|
||||
offset,
|
||||
});
|
||||
if (paragraphIdx !== -1) {
|
||||
return { index: paragraphIdx };
|
||||
@ -266,6 +288,7 @@ export class EmbeddedBlockChunker {
|
||||
fenceSpans,
|
||||
minChars,
|
||||
reverse: false,
|
||||
offset,
|
||||
});
|
||||
if (newlineIdx !== -1) {
|
||||
return { index: newlineIdx };
|
||||
@ -273,7 +296,7 @@ export class EmbeddedBlockChunker {
|
||||
}
|
||||
|
||||
if (preference !== "newline") {
|
||||
const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars);
|
||||
const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars, offset);
|
||||
if (sentenceIdx !== -1) {
|
||||
return { index: sentenceIdx };
|
||||
}
|
||||
@ -282,14 +305,18 @@ export class EmbeddedBlockChunker {
|
||||
return { index: -1 };
|
||||
}
|
||||
|
||||
#pickBreakIndex(buffer: string, minCharsOverride?: number): BreakResult {
|
||||
#pickBreakIndex(
|
||||
buffer: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
minCharsOverride?: number,
|
||||
offset = 0,
|
||||
): BreakResult {
|
||||
const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars));
|
||||
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
|
||||
if (buffer.length < minChars) {
|
||||
return { index: -1 };
|
||||
}
|
||||
const window = buffer.slice(0, Math.min(maxChars, buffer.length));
|
||||
const fenceSpans = parseFenceSpans(buffer);
|
||||
|
||||
const preference = this.#chunking.breakPreference ?? "paragraph";
|
||||
if (preference === "paragraph") {
|
||||
@ -298,6 +325,7 @@ export class EmbeddedBlockChunker {
|
||||
fenceSpans,
|
||||
minChars,
|
||||
reverse: true,
|
||||
offset,
|
||||
});
|
||||
if (paragraphIdx !== -1) {
|
||||
return { index: paragraphIdx };
|
||||
@ -310,6 +338,7 @@ export class EmbeddedBlockChunker {
|
||||
fenceSpans,
|
||||
minChars,
|
||||
reverse: true,
|
||||
offset,
|
||||
});
|
||||
if (newlineIdx !== -1) {
|
||||
return { index: newlineIdx };
|
||||
@ -317,7 +346,7 @@ export class EmbeddedBlockChunker {
|
||||
}
|
||||
|
||||
if (preference !== "newline") {
|
||||
const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars);
|
||||
const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars, offset);
|
||||
if (sentenceIdx !== -1) {
|
||||
return { index: sentenceIdx };
|
||||
}
|
||||
@ -328,22 +357,23 @@ export class EmbeddedBlockChunker {
|
||||
}
|
||||
|
||||
for (let i = window.length - 1; i >= minChars; i--) {
|
||||
if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, i)) {
|
||||
if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, offset + i)) {
|
||||
return { index: i };
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.length >= maxChars) {
|
||||
if (isSafeFenceBreak(fenceSpans, maxChars)) {
|
||||
if (isSafeFenceBreak(fenceSpans, offset + maxChars)) {
|
||||
return { index: maxChars };
|
||||
}
|
||||
const fence = findFenceSpanAt(fenceSpans, maxChars);
|
||||
const fence = findFenceSpanAt(fenceSpans, offset + maxChars);
|
||||
if (fence) {
|
||||
return {
|
||||
index: maxChars,
|
||||
fenceSplit: {
|
||||
closeFenceLine: `${fence.indent}${fence.marker}`,
|
||||
reopenFenceLine: fence.openLine,
|
||||
fence,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -354,12 +384,17 @@ export class EmbeddedBlockChunker {
|
||||
}
|
||||
}
|
||||
|
||||
function stripLeadingNewlines(value: string): string {
|
||||
let i = 0;
|
||||
function skipLeadingNewlines(value: string, start = 0): number {
|
||||
let i = start;
|
||||
while (i < value.length && value[i] === "\n") {
|
||||
i++;
|
||||
}
|
||||
return i > 0 ? value.slice(i) : value;
|
||||
return i;
|
||||
}
|
||||
|
||||
function stripLeadingNewlines(value: string): string {
|
||||
const start = skipLeadingNewlines(value);
|
||||
return start > 0 ? value.slice(start) : value;
|
||||
}
|
||||
|
||||
function findNextParagraphBreak(
|
||||
|
||||
@ -179,6 +179,28 @@ describe("buildInlineProviderModels", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves literal marker-shaped headers in inline provider models", () => {
|
||||
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
|
||||
custom: {
|
||||
headers: {
|
||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
||||
"X-Managed": "secretref-managed",
|
||||
"X-Static": "tenant-a",
|
||||
},
|
||||
models: [makeModel("custom-model")],
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildInlineProviderModels(providers);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].headers).toEqual({
|
||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
||||
"X-Managed": "secretref-managed",
|
||||
"X-Static": "tenant-a",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModel", () => {
|
||||
@ -223,6 +245,56 @@ describe("resolveModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves literal marker-shaped provider headers in fallback models", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "http://localhost:9000",
|
||||
headers: {
|
||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
||||
"X-Managed": "secretref-managed",
|
||||
"X-Custom-Auth": "token-123",
|
||||
},
|
||||
models: [makeModel("listed-model")],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
||||
"X-Managed": "secretref-managed",
|
||||
"X-Custom-Auth": "token-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops marker headers from discovered models.json entries", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "custom",
|
||||
modelId: "listed-model",
|
||||
templateModel: {
|
||||
...makeModel("listed-model"),
|
||||
provider: "custom",
|
||||
headers: {
|
||||
Authorization: "secretref-env:OPENAI_HEADER_TOKEN",
|
||||
"X-Managed": "secretref-managed",
|
||||
"X-Static": "tenant-a",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveModel("custom", "listed-model", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
"X-Static": "tenant-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers matching configured model metadata for fallback token limits", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js";
|
||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { buildModelAliasLines } from "../model-alias-lines.js";
|
||||
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
|
||||
import { normalizeModelCompat } from "../model-compat.js";
|
||||
import { resolveForwardCompatModel } from "../model-forward-compat.js";
|
||||
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
|
||||
@ -19,9 +20,29 @@ type InlineProviderConfig = {
|
||||
baseUrl?: string;
|
||||
api?: ModelDefinitionConfig["api"];
|
||||
models?: ModelDefinitionConfig[];
|
||||
headers?: Record<string, string>;
|
||||
headers?: unknown;
|
||||
};
|
||||
|
||||
function sanitizeModelHeaders(
|
||||
headers: unknown,
|
||||
opts?: { stripSecretRefMarkers?: boolean },
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
||||
return undefined;
|
||||
}
|
||||
const next: Record<string, string> = {};
|
||||
for (const [headerName, headerValue] of Object.entries(headers)) {
|
||||
if (typeof headerValue !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (opts?.stripSecretRefMarkers && isSecretRefHeaderValueMarker(headerValue)) {
|
||||
continue;
|
||||
}
|
||||
next[headerName] = headerValue;
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
export { buildModelAliasLines };
|
||||
|
||||
function resolveConfiguredProviderConfig(
|
||||
@ -46,16 +67,23 @@ function applyConfiguredProviderOverrides(params: {
|
||||
}): Model<Api> {
|
||||
const { discoveredModel, providerConfig, modelId } = params;
|
||||
if (!providerConfig) {
|
||||
return discoveredModel;
|
||||
return {
|
||||
...discoveredModel,
|
||||
// Discovered models originate from models.json and may contain persistence markers.
|
||||
headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }),
|
||||
};
|
||||
}
|
||||
const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId);
|
||||
if (
|
||||
!configuredModel &&
|
||||
!providerConfig.baseUrl &&
|
||||
!providerConfig.api &&
|
||||
!providerConfig.headers
|
||||
) {
|
||||
return discoveredModel;
|
||||
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
|
||||
stripSecretRefMarkers: true,
|
||||
});
|
||||
const providerHeaders = sanitizeModelHeaders(providerConfig.headers);
|
||||
const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers);
|
||||
if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) {
|
||||
return {
|
||||
...discoveredModel,
|
||||
headers: discoveredHeaders,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...discoveredModel,
|
||||
@ -67,13 +95,13 @@ function applyConfiguredProviderOverrides(params: {
|
||||
contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow,
|
||||
maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens,
|
||||
headers:
|
||||
providerConfig.headers || configuredModel?.headers
|
||||
discoveredHeaders || providerHeaders || configuredHeaders
|
||||
? {
|
||||
...discoveredModel.headers,
|
||||
...providerConfig.headers,
|
||||
...configuredModel?.headers,
|
||||
...discoveredHeaders,
|
||||
...providerHeaders,
|
||||
...configuredHeaders,
|
||||
}
|
||||
: discoveredModel.headers,
|
||||
: undefined,
|
||||
compat: configuredModel?.compat ?? discoveredModel.compat,
|
||||
};
|
||||
}
|
||||
@ -86,15 +114,22 @@ export function buildInlineProviderModels(
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
const providerHeaders = sanitizeModelHeaders(entry?.headers);
|
||||
return (entry?.models ?? []).map((model) => ({
|
||||
...model,
|
||||
provider: trimmed,
|
||||
baseUrl: entry?.baseUrl,
|
||||
api: model.api ?? entry?.api,
|
||||
headers:
|
||||
entry?.headers || (model as InlineModelEntry).headers
|
||||
? { ...entry?.headers, ...(model as InlineModelEntry).headers }
|
||||
: undefined,
|
||||
headers: (() => {
|
||||
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers);
|
||||
if (!providerHeaders && !modelHeaders) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...providerHeaders,
|
||||
...modelHeaders,
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
@ -161,6 +196,8 @@ export function resolveModelWithRegistry(params: {
|
||||
}
|
||||
|
||||
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
|
||||
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers);
|
||||
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers);
|
||||
if (providerConfig || modelId.startsWith("mock-")) {
|
||||
return normalizeModelCompat({
|
||||
id: modelId,
|
||||
@ -180,9 +217,7 @@ export function resolveModelWithRegistry(params: {
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers:
|
||||
providerConfig?.headers || configuredModel?.headers
|
||||
? { ...providerConfig?.headers, ...configuredModel?.headers }
|
||||
: undefined,
|
||||
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
|
||||
} as Model<Api>);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as fences from "../markdown/fences.js";
|
||||
import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js";
|
||||
import {
|
||||
chunkByNewline,
|
||||
@ -217,6 +218,17 @@ describe("chunkMarkdownText", () => {
|
||||
expect(chunks[0]?.length).toBe(20);
|
||||
expect(chunks.join("")).toBe(text);
|
||||
});
|
||||
|
||||
it("parses fence spans once for long fenced payloads", () => {
|
||||
const parseSpy = vi.spyOn(fences, "parseFenceSpans");
|
||||
const text = `\`\`\`txt\n${"line\n".repeat(600)}\`\`\``;
|
||||
|
||||
const chunks = chunkMarkdownText(text, 80);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(2);
|
||||
expect(parseSpy).toHaveBeenCalledTimes(1);
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkByNewline", () => {
|
||||
|
||||
@ -306,7 +306,7 @@ export function chunkText(text: string, limit: number): string[] {
|
||||
}
|
||||
return chunkTextByBreakResolver(text, limit, (window) => {
|
||||
// 1) Prefer a newline break inside the window (outside parentheses).
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, 0, window.length);
|
||||
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
|
||||
return lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||
});
|
||||
@ -319,14 +319,24 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
const spans = parseFenceSpans(text);
|
||||
let start = 0;
|
||||
let reopenFence: ReturnType<typeof findFenceSpanAt> | undefined;
|
||||
|
||||
while (remaining.length > limit) {
|
||||
const spans = parseFenceSpans(remaining);
|
||||
const window = remaining.slice(0, limit);
|
||||
while (start < text.length) {
|
||||
const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : "";
|
||||
const contentLimit = Math.max(1, limit - reopenPrefix.length);
|
||||
if (text.length - start <= contentLimit) {
|
||||
const finalChunk = `${reopenPrefix}${text.slice(start)}`;
|
||||
if (finalChunk.length > 0) {
|
||||
chunks.push(finalChunk);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const softBreak = pickSafeBreakIndex(window, spans);
|
||||
let breakIdx = softBreak > 0 ? softBreak : limit;
|
||||
const windowEnd = Math.min(text.length, start + contentLimit);
|
||||
const softBreak = pickSafeBreakIndex(text, start, windowEnd, spans);
|
||||
let breakIdx = softBreak > start ? softBreak : windowEnd;
|
||||
|
||||
const initialFence = isSafeFenceBreak(spans, breakIdx)
|
||||
? undefined
|
||||
@ -335,38 +345,38 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
let fenceToSplit = initialFence;
|
||||
if (initialFence) {
|
||||
const closeLine = `${initialFence.indent}${initialFence.marker}`;
|
||||
const maxIdxIfNeedNewline = limit - (closeLine.length + 1);
|
||||
const maxIdxIfNeedNewline = start + (contentLimit - (closeLine.length + 1));
|
||||
|
||||
if (maxIdxIfNeedNewline <= 0) {
|
||||
if (maxIdxIfNeedNewline <= start) {
|
||||
fenceToSplit = undefined;
|
||||
breakIdx = limit;
|
||||
breakIdx = windowEnd;
|
||||
} else {
|
||||
const minProgressIdx = Math.min(
|
||||
remaining.length,
|
||||
initialFence.start + initialFence.openLine.length + 2,
|
||||
text.length,
|
||||
Math.max(start + 1, initialFence.start + initialFence.openLine.length + 2),
|
||||
);
|
||||
const maxIdxIfAlreadyNewline = limit - closeLine.length;
|
||||
const maxIdxIfAlreadyNewline = start + (contentLimit - closeLine.length);
|
||||
|
||||
let pickedNewline = false;
|
||||
let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1));
|
||||
while (lastNewline !== -1) {
|
||||
let lastNewline = text.lastIndexOf("\n", Math.max(start, maxIdxIfAlreadyNewline - 1));
|
||||
while (lastNewline >= start) {
|
||||
const candidateBreak = lastNewline + 1;
|
||||
if (candidateBreak < minProgressIdx) {
|
||||
break;
|
||||
}
|
||||
const candidateFence = findFenceSpanAt(spans, candidateBreak);
|
||||
if (candidateFence && candidateFence.start === initialFence.start) {
|
||||
breakIdx = Math.max(1, candidateBreak);
|
||||
breakIdx = candidateBreak;
|
||||
pickedNewline = true;
|
||||
break;
|
||||
}
|
||||
lastNewline = remaining.lastIndexOf("\n", lastNewline - 1);
|
||||
lastNewline = text.lastIndexOf("\n", lastNewline - 1);
|
||||
}
|
||||
|
||||
if (!pickedNewline) {
|
||||
if (minProgressIdx > maxIdxIfAlreadyNewline) {
|
||||
fenceToSplit = undefined;
|
||||
breakIdx = limit;
|
||||
breakIdx = windowEnd;
|
||||
} else {
|
||||
breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
|
||||
}
|
||||
@ -378,68 +388,72 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
|
||||
fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined;
|
||||
}
|
||||
|
||||
let rawChunk = remaining.slice(0, breakIdx);
|
||||
if (!rawChunk) {
|
||||
const rawContent = text.slice(start, breakIdx);
|
||||
if (!rawContent) {
|
||||
break;
|
||||
}
|
||||
|
||||
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
let next = remaining.slice(nextStart);
|
||||
let rawChunk = `${reopenPrefix}${rawContent}`;
|
||||
const brokeOnSeparator = breakIdx < text.length && /\s/.test(text[breakIdx]);
|
||||
let nextStart = Math.min(text.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
|
||||
if (fenceToSplit) {
|
||||
const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`;
|
||||
rawChunk = rawChunk.endsWith("\n") ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`;
|
||||
next = `${fenceToSplit.openLine}\n${next}`;
|
||||
reopenFence = fenceToSplit;
|
||||
} else {
|
||||
next = stripLeadingNewlines(next);
|
||||
nextStart = skipLeadingNewlines(text, nextStart);
|
||||
reopenFence = undefined;
|
||||
}
|
||||
|
||||
chunks.push(rawChunk);
|
||||
remaining = next;
|
||||
}
|
||||
|
||||
if (remaining.length) {
|
||||
chunks.push(remaining);
|
||||
start = nextStart;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function stripLeadingNewlines(value: string): string {
|
||||
let i = 0;
|
||||
function skipLeadingNewlines(value: string, start = 0): number {
|
||||
let i = start;
|
||||
while (i < value.length && value[i] === "\n") {
|
||||
i++;
|
||||
}
|
||||
return i > 0 ? value.slice(i) : value;
|
||||
return i;
|
||||
}
|
||||
|
||||
function pickSafeBreakIndex(window: string, spans: ReturnType<typeof parseFenceSpans>): number {
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) =>
|
||||
function pickSafeBreakIndex(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
spans: ReturnType<typeof parseFenceSpans>,
|
||||
): number {
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(text, start, end, (index) =>
|
||||
isSafeFenceBreak(spans, index),
|
||||
);
|
||||
|
||||
if (lastNewline > 0) {
|
||||
if (lastNewline > start) {
|
||||
return lastNewline;
|
||||
}
|
||||
if (lastWhitespace > 0) {
|
||||
if (lastWhitespace > start) {
|
||||
return lastWhitespace;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function scanParenAwareBreakpoints(
|
||||
window: string,
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
isAllowed: (index: number) => boolean = () => true,
|
||||
): { lastNewline: number; lastWhitespace: number } {
|
||||
let lastNewline = -1;
|
||||
let lastWhitespace = -1;
|
||||
let depth = 0;
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
for (let i = start; i < end; i++) {
|
||||
if (!isAllowed(i)) {
|
||||
continue;
|
||||
}
|
||||
const char = window[i];
|
||||
const char = text[i];
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
continue;
|
||||
|
||||
@ -196,6 +196,31 @@ function extractConfigAllowlist(account: {
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePairingStoreAllowlist(params: {
|
||||
action: "add" | "remove";
|
||||
channelId: ChannelId;
|
||||
accountId?: string;
|
||||
entry: string;
|
||||
}) {
|
||||
const storeEntry = {
|
||||
channel: params.channelId,
|
||||
entry: params.entry,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
if (params.action === "add") {
|
||||
await addChannelAllowFromStoreEntry(storeEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeChannelAllowFromStoreEntry(storeEntry);
|
||||
if (params.accountId === DEFAULT_ACCOUNT_ID) {
|
||||
await removeChannelAllowFromStoreEntry({
|
||||
channel: params.channelId,
|
||||
entry: params.entry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAccountTarget(
|
||||
parsed: Record<string, unknown>,
|
||||
channelId: ChannelId,
|
||||
@ -695,11 +720,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
}
|
||||
|
||||
if (shouldTouchStore) {
|
||||
if (parsed.action === "add") {
|
||||
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
} else if (parsed.action === "remove") {
|
||||
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
}
|
||||
await updatePairingStoreAllowlist({
|
||||
action: parsed.action,
|
||||
channelId,
|
||||
accountId,
|
||||
entry: parsed.entry,
|
||||
});
|
||||
}
|
||||
|
||||
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||
@ -727,11 +753,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.action === "add") {
|
||||
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
} else if (parsed.action === "remove") {
|
||||
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
}
|
||||
await updatePairingStoreAllowlist({
|
||||
action: parsed.action,
|
||||
channelId,
|
||||
accountId,
|
||||
entry: parsed.entry,
|
||||
});
|
||||
|
||||
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||
|
||||
@ -704,10 +704,74 @@ describe("handleCommands /allowlist", () => {
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
|
||||
it("writes store entries to the selected account scope", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, {
|
||||
AccountId: "work",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
accountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes default-account entries from scoped and legacy pairing stores", async () => {
|
||||
removeChannelAllowFromStoreEntryMock
|
||||
.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildPolicyParams("/allowlist remove dm --store 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, {
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, {
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects blocked account ids and keeps Object.prototype clean", async () => {
|
||||
delete (Object.prototype as Record<string, unknown>).allowFrom;
|
||||
|
||||
|
||||
@ -46,6 +46,26 @@ async function inspectUnknownListenerFallback(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectAmbiguousOwnershipWithProbe(
|
||||
probeResult: Awaited<ReturnType<typeof probeGateway>>,
|
||||
) {
|
||||
const service = {
|
||||
readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })),
|
||||
} as unknown as GatewayService;
|
||||
|
||||
inspectPortUsage.mockResolvedValue({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
listeners: [{ commandLine: "" }],
|
||||
hints: [],
|
||||
});
|
||||
classifyPortListener.mockReturnValue("unknown");
|
||||
probeGateway.mockResolvedValue(probeResult);
|
||||
|
||||
const { inspectGatewayRestart } = await import("./restart-health.js");
|
||||
return inspectGatewayRestart({ service, port: 18789 });
|
||||
}
|
||||
|
||||
describe("inspectGatewayRestart", () => {
|
||||
beforeEach(() => {
|
||||
inspectPortUsage.mockReset();
|
||||
@ -159,25 +179,11 @@ describe("inspectGatewayRestart", () => {
|
||||
});
|
||||
|
||||
it("uses a local gateway probe when ownership is ambiguous", async () => {
|
||||
const service = {
|
||||
readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })),
|
||||
} as unknown as GatewayService;
|
||||
|
||||
inspectPortUsage.mockResolvedValue({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
listeners: [{ commandLine: "" }],
|
||||
hints: [],
|
||||
});
|
||||
classifyPortListener.mockReturnValue("unknown");
|
||||
probeGateway.mockResolvedValue({
|
||||
const snapshot = await inspectAmbiguousOwnershipWithProbe({
|
||||
ok: true,
|
||||
close: null,
|
||||
});
|
||||
|
||||
const { inspectGatewayRestart } = await import("./restart-health.js");
|
||||
const snapshot = await inspectGatewayRestart({ service, port: 18789 });
|
||||
|
||||
expect(snapshot.healthy).toBe(true);
|
||||
expect(probeGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
|
||||
@ -185,25 +191,11 @@ describe("inspectGatewayRestart", () => {
|
||||
});
|
||||
|
||||
it("treats auth-closed probe as healthy gateway reachability", async () => {
|
||||
const service = {
|
||||
readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })),
|
||||
} as unknown as GatewayService;
|
||||
|
||||
inspectPortUsage.mockResolvedValue({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
listeners: [{ commandLine: "" }],
|
||||
hints: [],
|
||||
});
|
||||
classifyPortListener.mockReturnValue("unknown");
|
||||
probeGateway.mockResolvedValue({
|
||||
const snapshot = await inspectAmbiguousOwnershipWithProbe({
|
||||
ok: false,
|
||||
close: { code: 1008, reason: "auth required" },
|
||||
});
|
||||
|
||||
const { inspectGatewayRestart } = await import("./restart-health.js");
|
||||
const snapshot = await inspectGatewayRestart({ service, port: 18789 });
|
||||
|
||||
expect(snapshot.healthy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -124,6 +124,41 @@ function mockAcpManager(params: {
|
||||
} as unknown as ReturnType<typeof acpManagerModule.getAcpSessionManager>);
|
||||
}
|
||||
|
||||
async function withAcpSessionEnv(fn: () => Promise<void>) {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
await fn();
|
||||
});
|
||||
}
|
||||
|
||||
function createRunTurnFromTextDeltas(chunks: string[]) {
|
||||
return vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
|
||||
};
|
||||
for (const text of chunks) {
|
||||
await params.onEvent?.({ type: "text_delta", text });
|
||||
}
|
||||
await params.onEvent?.({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeAssistantEvents() {
|
||||
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "assistant") {
|
||||
return;
|
||||
}
|
||||
assistantEvents.push({
|
||||
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
|
||||
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
|
||||
});
|
||||
});
|
||||
return { assistantEvents, stop };
|
||||
}
|
||||
|
||||
async function runAcpSessionWithPolicyOverrides(params: {
|
||||
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>;
|
||||
resolveSession?: Parameters<typeof mockAcpManager>[0]["resolveSession"];
|
||||
@ -161,19 +196,8 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
});
|
||||
|
||||
it("routes ACP sessions through AcpSessionManager instead of embedded agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
|
||||
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
|
||||
};
|
||||
await params.onEvent?.({ type: "text_delta", text: "ACP_" });
|
||||
await params.onEvent?.({ type: "text_delta", text: "OK" });
|
||||
await params.onEvent?.({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
await withAcpSessionEnv(async () => {
|
||||
const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
@ -197,31 +221,15 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
});
|
||||
|
||||
it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
|
||||
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "assistant") {
|
||||
return;
|
||||
}
|
||||
assistantEvents.push({
|
||||
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
|
||||
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
|
||||
};
|
||||
for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY", "Actual answer"]) {
|
||||
await params.onEvent?.({ type: "text_delta", text });
|
||||
}
|
||||
await params.onEvent?.({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
await withAcpSessionEnv(async () => {
|
||||
const { assistantEvents, stop } = subscribeAssistantEvents();
|
||||
const runTurn = createRunTurnFromTextDeltas([
|
||||
"NO",
|
||||
"NO_",
|
||||
"NO_RE",
|
||||
"NO_REPLY",
|
||||
"Actual answer",
|
||||
]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
@ -242,11 +250,7 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
});
|
||||
|
||||
it("keeps silent-only ACP turns out of assistant output", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
|
||||
await withAcpSessionEnv(async () => {
|
||||
const assistantEvents: string[] = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "assistant") {
|
||||
@ -257,15 +261,7 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
|
||||
};
|
||||
for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY"]) {
|
||||
await params.onEvent?.({ type: "text_delta", text });
|
||||
}
|
||||
await params.onEvent?.({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
const runTurn = createRunTurnFromTextDeltas(["NO", "NO_", "NO_RE", "NO_REPLY"]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
@ -286,31 +282,9 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
});
|
||||
|
||||
it("preserves repeated identical ACP delta chunks", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
|
||||
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "assistant") {
|
||||
return;
|
||||
}
|
||||
assistantEvents.push({
|
||||
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
|
||||
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
|
||||
};
|
||||
for (const text of ["b", "o", "o", "k"]) {
|
||||
await params.onEvent?.({ type: "text_delta", text });
|
||||
}
|
||||
await params.onEvent?.({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
await withAcpSessionEnv(async () => {
|
||||
const { assistantEvents, stop } = subscribeAssistantEvents();
|
||||
const runTurn = createRunTurnFromTextDeltas(["b", "o", "o", "k"]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
@ -335,31 +309,9 @@ describe("agentCommand ACP runtime routing", () => {
|
||||
});
|
||||
|
||||
it("re-emits buffered NO prefix when ACP text becomes visible content", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
|
||||
const assistantEvents: Array<{ text?: string; delta?: string }> = [];
|
||||
const stop = onAgentEvent((evt) => {
|
||||
if (evt.stream !== "assistant") {
|
||||
return;
|
||||
}
|
||||
assistantEvents.push({
|
||||
text: typeof evt.data?.text === "string" ? evt.data.text : undefined,
|
||||
delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const runTurn = vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise<void>;
|
||||
};
|
||||
for (const text of ["NO", "W"]) {
|
||||
await params.onEvent?.({ type: "text_delta", text });
|
||||
}
|
||||
await params.onEvent?.({ type: "done", stopReason: "stop" });
|
||||
});
|
||||
await withAcpSessionEnv(async () => {
|
||||
const { assistantEvents, stop } = subscribeAssistantEvents();
|
||||
const runTurn = createRunTurnFromTextDeltas(["NO", "W"]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
|
||||
@ -8,6 +8,7 @@ import { FailoverError } from "../agents/failover-error.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import * as modelSelectionModule from "../agents/model-selection.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import * as configModule from "../config/config.js";
|
||||
import * as sessionsModule from "../config/sessions.js";
|
||||
@ -51,6 +52,8 @@ const runtime: RuntimeEnv = {
|
||||
};
|
||||
|
||||
const configSpy = vi.spyOn(configModule, "loadConfig");
|
||||
const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite");
|
||||
const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot");
|
||||
const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent");
|
||||
const deliverAgentCommandResultSpy = vi.spyOn(agentDeliveryModule, "deliverAgentCommandResult");
|
||||
|
||||
@ -256,13 +259,91 @@ function createTelegramOutboundPlugin() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configModule.clearRuntimeConfigSnapshot();
|
||||
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
|
||||
readConfigFileSnapshotForWriteSpy.mockResolvedValue({
|
||||
snapshot: { valid: false, resolved: {} as OpenClawConfig },
|
||||
writeOptions: {},
|
||||
} as Awaited<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
|
||||
});
|
||||
|
||||
describe("agentCommand", () => {
|
||||
it("sets runtime snapshots from source config before embedded agent run", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
const loadedConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
models: { "anthropic/claude-opus-4-5": {} },
|
||||
workspace: path.join(home, "openclaw"),
|
||||
},
|
||||
},
|
||||
session: { store, mainKey: "main" },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const sourceConfig = {
|
||||
...loadedConfig,
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const resolvedConfig = {
|
||||
...loadedConfig,
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-resolved-runtime", // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
configSpy.mockReturnValue(loadedConfig);
|
||||
readConfigFileSnapshotForWriteSpy.mockResolvedValue({
|
||||
snapshot: { valid: true, resolved: sourceConfig },
|
||||
writeOptions: {},
|
||||
} as Awaited<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
|
||||
const resolveSecretsSpy = vi
|
||||
.spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway")
|
||||
.mockResolvedValueOnce({
|
||||
resolvedConfig,
|
||||
diagnostics: [],
|
||||
targetStatesByPath: {},
|
||||
hadUnresolvedTargets: false,
|
||||
});
|
||||
|
||||
await agentCommand({ message: "hello", to: "+1555" }, runtime);
|
||||
|
||||
expect(resolveSecretsSpy).toHaveBeenCalledWith({
|
||||
config: loadedConfig,
|
||||
commandName: "agent",
|
||||
targetIds: expect.any(Set),
|
||||
});
|
||||
expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig);
|
||||
expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a session entry when deriving from --to", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
|
||||
@ -57,7 +57,11 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
|
||||
import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshotForWrite,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../config/config.js";
|
||||
import {
|
||||
mergeSessionEntry,
|
||||
parseSessionThreadInfo,
|
||||
@ -427,11 +431,23 @@ async function agentCommandInternal(
|
||||
}
|
||||
|
||||
const loadedRaw = loadConfig();
|
||||
const sourceConfig = await (async () => {
|
||||
try {
|
||||
const { snapshot } = await readConfigFileSnapshotForWrite();
|
||||
if (snapshot.valid) {
|
||||
return snapshot.resolved;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to runtime-loaded config when source snapshot is unavailable.
|
||||
}
|
||||
return loadedRaw;
|
||||
})();
|
||||
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "agent",
|
||||
targetIds: getAgentRuntimeCommandSecretTargetIds(),
|
||||
});
|
||||
setRuntimeConfigSnapshot(cfg, sourceConfig);
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
}
|
||||
|
||||
@ -186,26 +186,94 @@ const createTelegramPollPluginRegistration = () => ({
|
||||
|
||||
const { messageCommand } = await import("./message.js");
|
||||
|
||||
function createTelegramSecretRawConfig() {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
token: { $secret: "vault://telegram/token" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTelegramResolvedTokenConfig(token: string) {
|
||||
return {
|
||||
channels: {
|
||||
telegram: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockResolvedCommandConfig(params: {
|
||||
rawConfig: Record<string, unknown>;
|
||||
resolvedConfig: Record<string, unknown>;
|
||||
diagnostics?: string[];
|
||||
}) {
|
||||
testConfig = params.rawConfig;
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
resolvedConfig: params.resolvedConfig,
|
||||
diagnostics: params.diagnostics ?? ["resolved channels.telegram.token"],
|
||||
});
|
||||
}
|
||||
|
||||
async function runTelegramDirectOutboundSend(params: {
|
||||
rawConfig: Record<string, unknown>;
|
||||
resolvedConfig: Record<string, unknown>;
|
||||
diagnostics?: string[];
|
||||
}) {
|
||||
mockResolvedCommandConfig(params);
|
||||
const sendText = vi.fn(async (_ctx: { cfg?: unknown; to?: string; text?: string }) => ({
|
||||
channel: "telegram" as const,
|
||||
messageId: "msg-1",
|
||||
chatId: "123456",
|
||||
}));
|
||||
const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||
channel: "telegram" as const,
|
||||
messageId: "msg-2",
|
||||
chatId: "123456",
|
||||
}));
|
||||
await setRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: createStubPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText,
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
await messageCommand(
|
||||
{
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
target: "123456",
|
||||
message: "hi",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
|
||||
return { sendText };
|
||||
}
|
||||
|
||||
describe("messageCommand", () => {
|
||||
it("threads resolved SecretRef config into outbound send actions", async () => {
|
||||
const rawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
token: { $secret: "vault://telegram/token" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
token: "12345:resolved-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
testConfig = rawConfig;
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
const rawConfig = createTelegramSecretRawConfig();
|
||||
const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token");
|
||||
mockResolvedCommandConfig({
|
||||
rawConfig: rawConfig as unknown as Record<string, unknown>,
|
||||
resolvedConfig: resolvedConfig as unknown as Record<string, unknown>,
|
||||
diagnostics: ["resolved channels.telegram.token"],
|
||||
});
|
||||
await setRegistry(
|
||||
createTestRegistry([
|
||||
@ -240,64 +308,12 @@ describe("messageCommand", () => {
|
||||
});
|
||||
|
||||
it("threads resolved SecretRef config into outbound adapter sends", async () => {
|
||||
const rawConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
token: { $secret: "vault://telegram/token" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
token: "12345:resolved-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
testConfig = rawConfig;
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
const rawConfig = createTelegramSecretRawConfig();
|
||||
const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token");
|
||||
const { sendText } = await runTelegramDirectOutboundSend({
|
||||
rawConfig: rawConfig as unknown as Record<string, unknown>,
|
||||
resolvedConfig: resolvedConfig as unknown as Record<string, unknown>,
|
||||
diagnostics: ["resolved channels.telegram.token"],
|
||||
});
|
||||
const sendText = vi.fn(async (_ctx: { cfg?: unknown; to: string; text: string }) => ({
|
||||
channel: "telegram" as const,
|
||||
messageId: "msg-1",
|
||||
chatId: "123456",
|
||||
}));
|
||||
const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||
channel: "telegram" as const,
|
||||
messageId: "msg-2",
|
||||
chatId: "123456",
|
||||
}));
|
||||
await setRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: createStubPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText,
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
await messageCommand(
|
||||
{
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
target: "123456",
|
||||
message: "hi",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -324,50 +340,11 @@ describe("messageCommand", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
testConfig = rawConfig;
|
||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
||||
const { sendText } = await runTelegramDirectOutboundSend({
|
||||
rawConfig: rawConfig as unknown as Record<string, unknown>,
|
||||
resolvedConfig: locallyResolvedConfig as unknown as Record<string, unknown>,
|
||||
diagnostics: ["gateway secrets.resolve unavailable; used local resolver fallback."],
|
||||
});
|
||||
const sendText = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||
channel: "telegram" as const,
|
||||
messageId: "msg-3",
|
||||
chatId: "123456",
|
||||
}));
|
||||
const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({
|
||||
channel: "telegram" as const,
|
||||
messageId: "msg-4",
|
||||
chatId: "123456",
|
||||
}));
|
||||
await setRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: createStubPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText,
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const deps = makeDeps();
|
||||
await messageCommand(
|
||||
{
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
target: "123456",
|
||||
message: "hi",
|
||||
},
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@ -5,6 +5,11 @@ let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegis
|
||||
let toModelRow: typeof import("./models/list.registry.js").toModelRow;
|
||||
|
||||
const loadConfig = vi.fn();
|
||||
const readConfigFileSnapshotForWrite = vi.fn().mockResolvedValue({
|
||||
snapshot: { valid: false, resolved: {} },
|
||||
writeOptions: {},
|
||||
});
|
||||
const setRuntimeConfigSnapshot = vi.fn();
|
||||
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
|
||||
const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent");
|
||||
const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} });
|
||||
@ -29,6 +34,8 @@ vi.mock("../config/config.js", () => ({
|
||||
CONFIG_PATH: "/tmp/openclaw.json",
|
||||
STATE_DIR: "/tmp/openclaw-state",
|
||||
loadConfig,
|
||||
readConfigFileSnapshotForWrite,
|
||||
setRuntimeConfigSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/models-config.js", () => ({
|
||||
@ -84,8 +91,16 @@ vi.mock("../agents/pi-model-discovery.js", () => {
|
||||
});
|
||||
|
||||
vi.mock("../agents/pi-embedded-runner/model.js", () => ({
|
||||
resolveModel: () => {
|
||||
throw new Error("resolveModel should not be called from models.list tests");
|
||||
resolveModelWithRegistry: ({
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
}: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, id: string) => unknown };
|
||||
}) => {
|
||||
return modelRegistry.find(provider, modelId);
|
||||
},
|
||||
}));
|
||||
|
||||
@ -114,6 +129,13 @@ beforeEach(() => {
|
||||
modelRegistryState.getAllError = undefined;
|
||||
modelRegistryState.getAvailableError = undefined;
|
||||
listProfilesForProvider.mockReturnValue([]);
|
||||
ensureOpenClawModelsJson.mockClear();
|
||||
readConfigFileSnapshotForWrite.mockClear();
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: { valid: false, resolved: {} },
|
||||
writeOptions: {},
|
||||
});
|
||||
setRuntimeConfigSnapshot.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -302,6 +324,35 @@ describe("models list/status", () => {
|
||||
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
|
||||
});
|
||||
|
||||
it("loadModelRegistry persists using source config snapshot when provided", async () => {
|
||||
modelRegistryState.models = [OPENAI_MODEL];
|
||||
modelRegistryState.available = [OPENAI_MODEL];
|
||||
const sourceConfig = {
|
||||
models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret
|
||||
};
|
||||
const resolvedConfig = {
|
||||
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
|
||||
};
|
||||
|
||||
await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never });
|
||||
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig);
|
||||
});
|
||||
|
||||
it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => {
|
||||
modelRegistryState.models = [OPENAI_MODEL];
|
||||
modelRegistryState.available = [OPENAI_MODEL];
|
||||
const resolvedConfig = {
|
||||
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
|
||||
};
|
||||
|
||||
await loadModelRegistry(resolvedConfig as never);
|
||||
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig);
|
||||
});
|
||||
|
||||
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {
|
||||
const row = toModelRow({
|
||||
model: makeGoogleAntigravityTemplate(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js";
|
||||
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
||||
|
||||
describe("resolveProviderAuthOverview", () => {
|
||||
@ -21,4 +22,52 @@ describe("resolveProviderAuthOverview", () => {
|
||||
|
||||
expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)");
|
||||
});
|
||||
|
||||
it("renders marker-backed models.json auth as marker detail", () => {
|
||||
const overview = resolveProviderAuthOverview({
|
||||
provider: "openai",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: NON_ENV_SECRETREF_MARKER,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
store: { version: 1, profiles: {} } as never,
|
||||
modelsPath: "/tmp/models.json",
|
||||
});
|
||||
|
||||
expect(overview.effective.kind).toBe("models.json");
|
||||
expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
|
||||
expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`);
|
||||
});
|
||||
|
||||
it("keeps env-var-shaped models.json values masked to avoid accidental plaintext exposure", () => {
|
||||
const overview = resolveProviderAuthOverview({
|
||||
provider: "openai",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "OPENAI_API_KEY", // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
store: { version: 1, profiles: {} } as never,
|
||||
modelsPath: "/tmp/models.json",
|
||||
});
|
||||
|
||||
expect(overview.effective.kind).toBe("models.json");
|
||||
expect(overview.effective.detail).not.toContain("marker(");
|
||||
expect(overview.effective.detail).not.toContain("OPENAI_API_KEY");
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,12 +6,19 @@ import {
|
||||
resolveAuthStorePathForDisplay,
|
||||
resolveProfileUnusableUntilForDisplay,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
|
||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { maskApiKey } from "./list.format.js";
|
||||
import type { ProviderAuthOverview } from "./list.types.js";
|
||||
|
||||
function formatMarkerOrSecret(value: string): string {
|
||||
return isNonSecretApiKeyMarker(value, { includeEnvVarName: false })
|
||||
? `marker(${value.trim()})`
|
||||
: maskApiKey(value);
|
||||
}
|
||||
|
||||
function formatProfileSecretLabel(params: {
|
||||
value: string | undefined;
|
||||
ref: { source: string; id: string } | undefined;
|
||||
@ -19,7 +26,8 @@ function formatProfileSecretLabel(params: {
|
||||
}): string {
|
||||
const value = typeof params.value === "string" ? params.value.trim() : "";
|
||||
if (value) {
|
||||
return params.kind === "token" ? `token:${maskApiKey(value)}` : maskApiKey(value);
|
||||
const display = formatMarkerOrSecret(value);
|
||||
return params.kind === "token" ? `token:${display}` : display;
|
||||
}
|
||||
if (params.ref) {
|
||||
const refLabel = `ref(${params.ref.source}:${params.ref.id})`;
|
||||
@ -108,7 +116,7 @@ export function resolveProviderAuthOverview(params: {
|
||||
};
|
||||
}
|
||||
if (customKey) {
|
||||
return { kind: "models.json", detail: maskApiKey(customKey) };
|
||||
return { kind: "models.json", detail: formatMarkerOrSecret(customKey) };
|
||||
}
|
||||
return { kind: "missing", detail: "missing" };
|
||||
})();
|
||||
@ -137,7 +145,7 @@ export function resolveProviderAuthOverview(params: {
|
||||
...(customKey
|
||||
? {
|
||||
modelsJson: {
|
||||
value: maskApiKey(customKey),
|
||||
value: formatMarkerOrSecret(customKey),
|
||||
source: `models.json: ${shortenHomePath(params.modelsPath)}`,
|
||||
},
|
||||
}
|
||||
|
||||
@ -2,11 +2,38 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const printModelTable = vi.fn();
|
||||
const sourceConfig = {
|
||||
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "$OPENAI_API_KEY", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig = {
|
||||
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "sk-resolved-runtime-value", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
loadConfig: vi.fn().mockReturnValue({
|
||||
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
|
||||
models: { providers: {} },
|
||||
}),
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
loadModelsConfigWithSource: vi.fn().mockResolvedValue({
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
diagnostics: [],
|
||||
}),
|
||||
ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }),
|
||||
loadModelRegistry: vi
|
||||
.fn()
|
||||
@ -58,6 +85,10 @@ vi.mock("./list.registry.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./load-config.js", () => ({
|
||||
loadModelsConfigWithSource: mocks.loadModelsConfigWithSource,
|
||||
}));
|
||||
|
||||
vi.mock("./list.configured.js", () => ({
|
||||
resolveConfiguredEntries: mocks.resolveConfiguredEntries,
|
||||
}));
|
||||
@ -95,6 +126,16 @@ describe("modelsListCommand forward-compat", () => {
|
||||
expect(codex?.tags).not.toContain("missing");
|
||||
});
|
||||
|
||||
it("passes source config to model registry loading for persistence safety", async () => {
|
||||
const runtime = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
await modelsListCommand({ json: true }, runtime as never);
|
||||
|
||||
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, {
|
||||
sourceConfig: mocks.sourceConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => {
|
||||
mocks.resolveConfiguredEntries.mockReturnValueOnce({
|
||||
entries: [
|
||||
|
||||
@ -8,7 +8,7 @@ import { formatErrorWithStack } from "./list.errors.js";
|
||||
import { loadModelRegistry, toModelRow } from "./list.registry.js";
|
||||
import { printModelTable } from "./list.table.js";
|
||||
import type { ModelRow } from "./list.types.js";
|
||||
import { loadModelsConfig } from "./load-config.js";
|
||||
import { loadModelsConfigWithSource } from "./load-config.js";
|
||||
import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js";
|
||||
|
||||
export async function modelsListCommand(
|
||||
@ -23,7 +23,10 @@ export async function modelsListCommand(
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
|
||||
const cfg = await loadModelsConfig({ commandName: "models list", runtime });
|
||||
const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({
|
||||
commandName: "models list",
|
||||
runtime,
|
||||
});
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const providerFilter = (() => {
|
||||
const raw = opts.provider?.trim();
|
||||
@ -39,7 +42,7 @@ export async function modelsListCommand(
|
||||
let availableKeys: Set<string> | undefined;
|
||||
let availabilityErrorMessage: string | undefined;
|
||||
try {
|
||||
const loaded = await loadModelRegistry(cfg);
|
||||
const loaded = await loadModelRegistry(cfg, { sourceConfig });
|
||||
modelRegistry = loaded.registry;
|
||||
models = loaded.models;
|
||||
availableKeys = loaded.availableKeys;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
|
||||
import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
let mockStore: AuthProfileStore;
|
||||
@ -39,6 +40,34 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
|
||||
|
||||
const { buildProbeTargets } = await import("./list.probe.js");
|
||||
|
||||
async function buildAnthropicProbePlan(order: string[]) {
|
||||
return buildProbeTargets({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: order,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
providers: ["anthropic"],
|
||||
modelCandidates: ["anthropic/claude-sonnet-4-6"],
|
||||
options: {
|
||||
timeoutMs: 5_000,
|
||||
concurrency: 1,
|
||||
maxTokens: 16,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expectLegacyMissingCredentialsError(
|
||||
result: { reasonCode?: string; error?: string } | undefined,
|
||||
reasonCode: string,
|
||||
) {
|
||||
expect(result?.reasonCode).toBe(reasonCode);
|
||||
expect(result?.error?.split("\n")[0]).toBe("Auth profile credentials are missing or expired.");
|
||||
expect(result?.error).toContain(`[${reasonCode}]`);
|
||||
}
|
||||
|
||||
describe("buildProbeTargets reason codes", () => {
|
||||
beforeEach(() => {
|
||||
mockStore = {
|
||||
@ -67,52 +96,18 @@ describe("buildProbeTargets reason codes", () => {
|
||||
});
|
||||
|
||||
it("reports invalid_expires with a legacy-compatible first error line", async () => {
|
||||
const plan = await buildProbeTargets({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:default"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
providers: ["anthropic"],
|
||||
modelCandidates: ["anthropic/claude-sonnet-4-6"],
|
||||
options: {
|
||||
timeoutMs: 5_000,
|
||||
concurrency: 1,
|
||||
maxTokens: 16,
|
||||
},
|
||||
});
|
||||
const plan = await buildAnthropicProbePlan(["anthropic:default"]);
|
||||
|
||||
expect(plan.targets).toHaveLength(0);
|
||||
expect(plan.results).toHaveLength(1);
|
||||
expect(plan.results[0]?.reasonCode).toBe("invalid_expires");
|
||||
expect(plan.results[0]?.error?.split("\n")[0]).toBe(
|
||||
"Auth profile credentials are missing or expired.",
|
||||
);
|
||||
expect(plan.results[0]?.error).toContain("[invalid_expires]");
|
||||
expectLegacyMissingCredentialsError(plan.results[0], "invalid_expires");
|
||||
});
|
||||
|
||||
it("reports excluded_by_auth_order when profile id is not present in explicit order", async () => {
|
||||
mockStore.order = {
|
||||
anthropic: ["anthropic:work"],
|
||||
};
|
||||
const plan = await buildProbeTargets({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:work"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
providers: ["anthropic"],
|
||||
modelCandidates: ["anthropic/claude-sonnet-4-6"],
|
||||
options: {
|
||||
timeoutMs: 5_000,
|
||||
concurrency: 1,
|
||||
maxTokens: 16,
|
||||
},
|
||||
});
|
||||
const plan = await buildAnthropicProbePlan(["anthropic:work"]);
|
||||
|
||||
expect(plan.targets).toHaveLength(0);
|
||||
expect(plan.results).toHaveLength(1);
|
||||
@ -137,30 +132,116 @@ describe("buildProbeTargets reason codes", () => {
|
||||
mockAllowedProfiles = ["anthropic:default"];
|
||||
resolveSecretRefStringMock.mockRejectedValueOnce(new Error("missing secret"));
|
||||
|
||||
const plan = await buildProbeTargets({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: {
|
||||
anthropic: ["anthropic:default"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
providers: ["anthropic"],
|
||||
modelCandidates: ["anthropic/claude-sonnet-4-6"],
|
||||
options: {
|
||||
timeoutMs: 5_000,
|
||||
concurrency: 1,
|
||||
maxTokens: 16,
|
||||
},
|
||||
});
|
||||
const plan = await buildAnthropicProbePlan(["anthropic:default"]);
|
||||
|
||||
expect(plan.targets).toHaveLength(0);
|
||||
expect(plan.results).toHaveLength(1);
|
||||
expect(plan.results[0]?.reasonCode).toBe("unresolved_ref");
|
||||
expect(plan.results[0]?.error?.split("\n")[0]).toBe(
|
||||
"Auth profile credentials are missing or expired.",
|
||||
);
|
||||
expect(plan.results[0]?.error).toContain("[unresolved_ref]");
|
||||
expectLegacyMissingCredentialsError(plan.results[0], "unresolved_ref");
|
||||
expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN");
|
||||
});
|
||||
|
||||
it("skips marker-only models.json credentials when building probe targets", async () => {
|
||||
const previousAnthropic = process.env.ANTHROPIC_API_KEY;
|
||||
const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
mockStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
order: {},
|
||||
};
|
||||
try {
|
||||
const plan = await buildProbeTargets({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
api: "anthropic-messages",
|
||||
apiKey: OLLAMA_LOCAL_AUTH_MARKER,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
providers: ["anthropic"],
|
||||
modelCandidates: ["anthropic/claude-sonnet-4-6"],
|
||||
options: {
|
||||
timeoutMs: 5_000,
|
||||
concurrency: 1,
|
||||
maxTokens: 16,
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan.targets).toEqual([]);
|
||||
expect(plan.results).toEqual([]);
|
||||
} finally {
|
||||
if (previousAnthropic === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = previousAnthropic;
|
||||
}
|
||||
if (previousAnthropicOauth === undefined) {
|
||||
delete process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
} else {
|
||||
process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat arbitrary all-caps models.json apiKey values as markers", async () => {
|
||||
const previousAnthropic = process.env.ANTHROPIC_API_KEY;
|
||||
const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
mockStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
order: {},
|
||||
};
|
||||
try {
|
||||
const plan = await buildProbeTargets({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
providers: ["anthropic"],
|
||||
modelCandidates: ["anthropic/claude-sonnet-4-6"],
|
||||
options: {
|
||||
timeoutMs: 5_000,
|
||||
concurrency: 1,
|
||||
maxTokens: 16,
|
||||
},
|
||||
});
|
||||
|
||||
expect(plan.results).toEqual([]);
|
||||
expect(plan.targets).toHaveLength(1);
|
||||
expect(plan.targets[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
source: "models.json",
|
||||
label: "models.json",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (previousAnthropic === undefined) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY = previousAnthropic;
|
||||
}
|
||||
if (previousAnthropicOauth === undefined) {
|
||||
delete process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
} else {
|
||||
process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { describeFailoverError } from "../../agents/failover-error.js";
|
||||
import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js";
|
||||
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||
import {
|
||||
@ -373,7 +374,8 @@ export async function buildProbeTargets(params: {
|
||||
|
||||
const envKey = resolveEnvApiKey(providerKey);
|
||||
const customKey = getCustomProviderApiKey(cfg, providerKey);
|
||||
if (!envKey && !customKey) {
|
||||
const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey));
|
||||
if (!envKey && !hasUsableModelsJsonKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -94,8 +94,13 @@ function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadModelRegistry(cfg: OpenClawConfig) {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
export async function loadModelRegistry(
|
||||
cfg: OpenClawConfig,
|
||||
opts?: { sourceConfig?: OpenClawConfig },
|
||||
) {
|
||||
// Persistence must be based on source config (pre-resolution) so SecretRef-managed
|
||||
// credentials remain markers in models.json for command paths too.
|
||||
await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg);
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const registry = discoverModels(authStorage, agentDir);
|
||||
|
||||
103
src/commands/models/load-config.test.ts
Normal file
103
src/commands/models/load-config.test.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
readConfigFileSnapshotForWrite: vi.fn(),
|
||||
setRuntimeConfigSnapshot: vi.fn(),
|
||||
resolveCommandSecretRefsViaGateway: vi.fn(),
|
||||
getModelsCommandSecretTargetIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite,
|
||||
setRuntimeConfigSnapshot: mocks.setRuntimeConfigSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||
}));
|
||||
|
||||
vi.mock("../../cli/command-secret-targets.js", () => ({
|
||||
getModelsCommandSecretTargetIds: mocks.getModelsCommandSecretTargetIds,
|
||||
}));
|
||||
|
||||
import { loadModelsConfig, loadModelsConfigWithSource } from "./load-config.js";
|
||||
|
||||
describe("models load-config", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns source+resolved configs and sets runtime snapshot", async () => {
|
||||
const sourceConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig = {
|
||||
models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret
|
||||
};
|
||||
const resolvedConfig = {
|
||||
models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret
|
||||
};
|
||||
const targetIds = new Set(["models.providers.*.apiKey"]);
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
mocks.loadConfig.mockReturnValue(runtimeConfig);
|
||||
mocks.readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: { valid: true, resolved: sourceConfig },
|
||||
writeOptions: {},
|
||||
});
|
||||
mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds);
|
||||
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig,
|
||||
diagnostics: ["diag-one", "diag-two"],
|
||||
});
|
||||
|
||||
const result = await loadModelsConfigWithSource({ commandName: "models list", runtime });
|
||||
|
||||
expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({
|
||||
config: runtimeConfig,
|
||||
commandName: "models list",
|
||||
targetIds,
|
||||
});
|
||||
expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig);
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(1, "[secrets] diag-one");
|
||||
expect(runtime.log).toHaveBeenNthCalledWith(2, "[secrets] diag-two");
|
||||
expect(result).toEqual({
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
diagnostics: ["diag-one", "diag-two"],
|
||||
});
|
||||
});
|
||||
|
||||
it("loadModelsConfig returns resolved config while preserving runtime snapshot behavior", async () => {
|
||||
const sourceConfig = { models: { providers: {} } };
|
||||
const runtimeConfig = {
|
||||
models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret
|
||||
};
|
||||
const resolvedConfig = {
|
||||
models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret
|
||||
};
|
||||
const targetIds = new Set(["models.providers.*.apiKey"]);
|
||||
|
||||
mocks.loadConfig.mockReturnValue(runtimeConfig);
|
||||
mocks.readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: { valid: true, resolved: sourceConfig },
|
||||
writeOptions: {},
|
||||
});
|
||||
mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds);
|
||||
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig,
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await expect(loadModelsConfig({ commandName: "models list" })).resolves.toBe(resolvedConfig);
|
||||
expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig);
|
||||
});
|
||||
});
|
||||
@ -1,15 +1,39 @@
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshotForWrite,
|
||||
setRuntimeConfigSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
export async function loadModelsConfig(params: {
|
||||
export type LoadedModelsConfig = {
|
||||
sourceConfig: OpenClawConfig;
|
||||
resolvedConfig: OpenClawConfig;
|
||||
diagnostics: string[];
|
||||
};
|
||||
|
||||
async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise<OpenClawConfig> {
|
||||
try {
|
||||
const { snapshot } = await readConfigFileSnapshotForWrite();
|
||||
if (snapshot.valid) {
|
||||
return snapshot.resolved;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to runtime-loaded config if source snapshot cannot be read.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function loadModelsConfigWithSource(params: {
|
||||
commandName: string;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const loadedRaw = loadConfig();
|
||||
}): Promise<LoadedModelsConfig> {
|
||||
const runtimeConfig = loadConfig();
|
||||
const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig);
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
config: runtimeConfig,
|
||||
commandName: params.commandName,
|
||||
targetIds: getModelsCommandSecretTargetIds(),
|
||||
});
|
||||
@ -18,5 +42,17 @@ export async function loadModelsConfig(params: {
|
||||
params.runtime.log(`[secrets] ${entry}`);
|
||||
}
|
||||
}
|
||||
return resolvedConfig;
|
||||
setRuntimeConfigSnapshot(resolvedConfig, sourceConfig);
|
||||
return {
|
||||
sourceConfig,
|
||||
resolvedConfig,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadModelsConfig(params: {
|
||||
commandName: string;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<OpenClawConfig> {
|
||||
return (await loadModelsConfigWithSource(params)).resolvedConfig;
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
} from "./onboard-config.js";
|
||||
|
||||
describe("applyOnboardingLocalWorkspaceConfig", () => {
|
||||
it("defaults local onboarding tool profile to coding", () => {
|
||||
expect(ONBOARDING_DEFAULT_TOOLS_PROFILE).toBe("coding");
|
||||
});
|
||||
|
||||
it("sets secure dmScope default when unset", () => {
|
||||
const baseConfig: OpenClawConfig = {};
|
||||
const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace");
|
||||
|
||||
@ -3,7 +3,7 @@ import type { DmScope } from "../config/types.base.js";
|
||||
import type { ToolProfileId } from "../config/types.tools.js";
|
||||
|
||||
export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer";
|
||||
export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "messaging";
|
||||
export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "coding";
|
||||
|
||||
export function applyOnboardingLocalWorkspaceConfig(
|
||||
baseConfig: OpenClawConfig,
|
||||
|
||||
@ -145,7 +145,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
||||
}>(configPath);
|
||||
|
||||
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
|
||||
expect(cfg?.tools?.profile).toBe("messaging");
|
||||
expect(cfg?.tools?.profile).toBe("coding");
|
||||
expect(cfg?.gateway?.auth?.mode).toBe("token");
|
||||
expect(cfg?.gateway?.auth?.token).toBe(token);
|
||||
});
|
||||
|
||||
@ -34,6 +34,44 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
return { prompter, notes };
|
||||
}
|
||||
|
||||
function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConfig {
|
||||
return {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
...(enabled === undefined ? {} : { enabled }),
|
||||
perplexity: { apiKey },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runBlankPerplexityKeyEntry(
|
||||
apiKey: string,
|
||||
enabled?: boolean,
|
||||
): Promise<OpenClawConfig> {
|
||||
const cfg = createPerplexityConfig(apiKey, enabled);
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "perplexity",
|
||||
textValue: "",
|
||||
});
|
||||
return setupSearch(cfg, runtime, prompter);
|
||||
}
|
||||
|
||||
async function runQuickstartPerplexitySetup(
|
||||
apiKey: string,
|
||||
enabled?: boolean,
|
||||
): Promise<{ result: OpenClawConfig; prompter: WizardPrompter }> {
|
||||
const cfg = createPerplexityConfig(apiKey, enabled);
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
return { result, prompter };
|
||||
}
|
||||
|
||||
describe("setupSearch", () => {
|
||||
it("returns config unchanged when user skips", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
@ -103,74 +141,49 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
it("shows missing-key note when no key is provided and no env var", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter, notes } = createPrompter({
|
||||
selectValue: "brave",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
const missingNote = notes.find((n) => n.message.includes("No API key stored"));
|
||||
expect(missingNote).toBeDefined();
|
||||
const original = process.env.BRAVE_API_KEY;
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
try {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter, notes } = createPrompter({
|
||||
selectValue: "brave",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
const missingNote = notes.find((n) => n.message.includes("No API key stored"));
|
||||
expect(missingNote).toBeDefined();
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
} else {
|
||||
process.env.BRAVE_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps existing key when user leaves input blank", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: { apiKey: "existing-key" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "perplexity",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
const result = await runBlankPerplexityKeyEntry(
|
||||
"existing-key", // pragma: allowlist secret
|
||||
);
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("advanced preserves enabled:false when keeping existing key", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
enabled: false,
|
||||
perplexity: { apiKey: "existing-key" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "perplexity",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
const result = await runBlankPerplexityKeyEntry(
|
||||
"existing-key", // pragma: allowlist secret
|
||||
false,
|
||||
);
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("quickstart skips key prompt when config key exists", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: { apiKey: "stored-pplx-key" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
const { result, prompter } = await runQuickstartPerplexitySetup(
|
||||
"stored-pplx-key", // pragma: allowlist secret
|
||||
);
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
@ -178,21 +191,10 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
it("quickstart preserves enabled:false when search was intentionally disabled", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
enabled: false,
|
||||
perplexity: { apiKey: "stored-pplx-key" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
const { result, prompter } = await runQuickstartPerplexitySetup(
|
||||
"stored-pplx-key", // pragma: allowlist secret
|
||||
false,
|
||||
);
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
@ -200,14 +202,24 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
it("quickstart falls through to key prompt when no key and no env var", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "grok", textValue: "" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(prompter.text).toHaveBeenCalled();
|
||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
const original = process.env.XAI_API_KEY;
|
||||
delete process.env.XAI_API_KEY;
|
||||
try {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "grok", textValue: "" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(prompter.text).toHaveBeenCalled();
|
||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.XAI_API_KEY;
|
||||
} else {
|
||||
process.env.XAI_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("quickstart skips key prompt when env var is available", async () => {
|
||||
|
||||
@ -154,6 +154,35 @@ describe("config identity defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts SecretRef values in model provider headers", async () => {
|
||||
await withTempHome("openclaw-config-identity-", async (home) => {
|
||||
const cfg = await writeAndLoadConfig(home, {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
headers: {
|
||||
Authorization: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_HEADER_TOKEN",
|
||||
},
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.models?.providers?.openai?.headers?.Authorization).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_HEADER_TOKEN",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("respects empty responsePrefix to disable identity defaults", async () => {
|
||||
await withTempHome("openclaw-config-identity-", async (home) => {
|
||||
const cfg = await writeAndLoadConfig(home, configWithDefaultIdentity({ responsePrefix: "" }));
|
||||
|
||||
@ -3,6 +3,7 @@ export {
|
||||
clearRuntimeConfigSnapshot,
|
||||
createConfigIO,
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
loadConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
|
||||
@ -5,6 +5,7 @@ import { withTempHome } from "./home-env.test-harness.js";
|
||||
import {
|
||||
clearConfigCache,
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
loadConfig,
|
||||
setRuntimeConfigSnapshot,
|
||||
writeConfigFile,
|
||||
@ -12,6 +13,70 @@ import {
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
describe("runtime config snapshot writes", () => {
|
||||
it("returns the source snapshot when runtime snapshot is active", async () => {
|
||||
await withTempHome("openclaw-config-runtime-source-", async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-runtime-resolved",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
expect(getRuntimeConfigSourceSnapshot()).toEqual(sourceConfig);
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("clears runtime source snapshot when runtime snapshot is cleared", async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-runtime-resolved",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
expect(getRuntimeConfigSourceSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves source secret refs when writeConfigFile receives runtime-resolved config", async () => {
|
||||
await withTempHome("openclaw-config-runtime-write-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@ -1345,6 +1345,10 @@ export function getRuntimeConfigSnapshot(): OpenClawConfig | null {
|
||||
return runtimeConfigSnapshot;
|
||||
}
|
||||
|
||||
export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
|
||||
return runtimeConfigSourceSnapshot;
|
||||
}
|
||||
|
||||
export function loadConfig(): OpenClawConfig {
|
||||
if (runtimeConfigSnapshot) {
|
||||
return runtimeConfigSnapshot;
|
||||
|
||||
@ -775,6 +775,9 @@ describe("config help copy quality", () => {
|
||||
it("documents auth/model root semantics and provider secret handling", () => {
|
||||
const providerKey = FIELD_HELP["models.providers.*.apiKey"];
|
||||
expect(/secret|env|credential/i.test(providerKey)).toBe(true);
|
||||
const modelsMode = FIELD_HELP["models.mode"];
|
||||
expect(modelsMode.includes("SecretRef-managed")).toBe(true);
|
||||
expect(modelsMode.includes("preserve")).toBe(true);
|
||||
|
||||
const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"];
|
||||
expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true);
|
||||
|
||||
@ -688,7 +688,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
models:
|
||||
"Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.",
|
||||
"models.mode":
|
||||
'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing; matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.',
|
||||
'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.',
|
||||
"models.providers":
|
||||
"Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.",
|
||||
"models.providers.*.baseUrl":
|
||||
|
||||
@ -135,6 +135,7 @@ describe("mapSensitivePaths", () => {
|
||||
expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true);
|
||||
expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true);
|
||||
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
|
||||
expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true);
|
||||
expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -54,7 +54,7 @@ export type ModelProviderConfig = {
|
||||
auth?: ModelProviderAuthMode;
|
||||
api?: ModelApi;
|
||||
injectNumCtxForOpenAICompat?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
headers?: Record<string, SecretInput>;
|
||||
authHeader?: boolean;
|
||||
models: ModelDefinitionConfig[];
|
||||
};
|
||||
|
||||
@ -234,7 +234,7 @@ export const ModelProviderSchema = z
|
||||
.optional(),
|
||||
api: ModelApiSchema.optional(),
|
||||
injectNumCtxForOpenAICompat: z.boolean().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(),
|
||||
authHeader: z.boolean().optional(),
|
||||
models: z.array(ModelDefinitionSchema),
|
||||
})
|
||||
|
||||
@ -17,6 +17,21 @@ const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
|
||||
describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
setupRunCronIsolatedAgentTurnSuite();
|
||||
|
||||
const mockFallbackPassthrough = () => {
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
};
|
||||
|
||||
const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => {
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(expectedFallbackCalls);
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(expectedAgentCalls);
|
||||
return result;
|
||||
};
|
||||
|
||||
const usePayloadTextExtraction = () => {
|
||||
pickLastNonEmptyTextFromPayloadsMock.mockImplementation(
|
||||
(payloads?: Array<{ text?: string }>) => {
|
||||
@ -47,16 +62,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
});
|
||||
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(2);
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2);
|
||||
mockFallbackPassthrough();
|
||||
await runTurnAndExpectOk(2, 2);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[1]?.[0]?.prompt).toContain(
|
||||
"previous response was only an acknowledgement",
|
||||
);
|
||||
@ -69,16 +76,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
});
|
||||
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
mockFallbackPassthrough();
|
||||
await runTurnAndExpectOk(1, 1);
|
||||
});
|
||||
|
||||
it("does not retry when descendants were spawned in this run even if they already settled", async () => {
|
||||
@ -94,15 +93,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
]);
|
||||
countActiveDescendantRunsMock.mockReturnValue(0);
|
||||
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
mockFallbackPassthrough();
|
||||
await runTurnAndExpectOk(1, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,6 +30,22 @@ function resolveCachedCron(expr: string, timezone: string): Cron {
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveCronFromSchedule(schedule: {
|
||||
tz?: string;
|
||||
expr?: unknown;
|
||||
cron?: unknown;
|
||||
}): Cron | undefined {
|
||||
const exprSource = typeof schedule.expr === "string" ? schedule.expr : schedule.cron;
|
||||
if (typeof exprSource !== "string") {
|
||||
throw new Error("invalid cron schedule: expr is required");
|
||||
}
|
||||
const expr = exprSource.trim();
|
||||
if (!expr) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
|
||||
}
|
||||
|
||||
export function coerceFiniteScheduleNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
@ -81,16 +97,10 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
|
||||
return anchor + steps * everyMs;
|
||||
}
|
||||
|
||||
const cronSchedule = schedule as { expr?: unknown; cron?: unknown };
|
||||
const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron;
|
||||
if (typeof exprSource !== "string") {
|
||||
throw new Error("invalid cron schedule: expr is required");
|
||||
}
|
||||
const expr = exprSource.trim();
|
||||
if (!expr) {
|
||||
const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown });
|
||||
if (!cron) {
|
||||
return undefined;
|
||||
}
|
||||
const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
|
||||
let next = cron.nextRun(new Date(nowMs));
|
||||
if (!next) {
|
||||
return undefined;
|
||||
@ -132,16 +142,10 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n
|
||||
if (schedule.kind !== "cron") {
|
||||
return undefined;
|
||||
}
|
||||
const cronSchedule = schedule as { expr?: unknown; cron?: unknown };
|
||||
const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron;
|
||||
if (typeof exprSource !== "string") {
|
||||
throw new Error("invalid cron schedule: expr is required");
|
||||
}
|
||||
const expr = exprSource.trim();
|
||||
if (!expr) {
|
||||
const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown });
|
||||
if (!cron) {
|
||||
return undefined;
|
||||
}
|
||||
const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
|
||||
const previousRuns = cron.previousRuns(1, new Date(nowMs));
|
||||
const previous = previousRuns[0];
|
||||
if (!previous) {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
import {
|
||||
DEFAULT_DISCORD_BOT_USER_ID,
|
||||
createDiscordHandlerParams,
|
||||
createDiscordPreflightContext,
|
||||
} from "./message-handler.test-helpers.js";
|
||||
|
||||
const preflightDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
const processDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
@ -15,53 +18,12 @@ vi.mock("./message-handler.process.js", () => ({
|
||||
|
||||
const { createDiscordMessageHandler } = await import("./message-handler.js");
|
||||
|
||||
const BOT_USER_ID = "bot-123";
|
||||
|
||||
function createHandlerParams(overrides?: Partial<{ botUserId: string }>) {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord,
|
||||
accountId: "default",
|
||||
token: "test-token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: overrides?.botUserId ?? BOT_USER_ID,
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off" as const,
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageData(authorId: string, channelId = "ch-1") {
|
||||
return {
|
||||
author: { id: authorId, bot: authorId === BOT_USER_ID },
|
||||
author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID },
|
||||
message: {
|
||||
id: "msg-1",
|
||||
author: { id: authorId, bot: authorId === BOT_USER_ID },
|
||||
author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID },
|
||||
content: "hello",
|
||||
channel_id: channelId,
|
||||
},
|
||||
@ -70,26 +32,7 @@ function createMessageData(authorId: string, channelId = "ch-1") {
|
||||
}
|
||||
|
||||
function createPreflightContext(channelId = "ch-1") {
|
||||
return {
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
message: {
|
||||
id: `msg-${channelId}`,
|
||||
channel_id: channelId,
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
message: {
|
||||
id: `msg-${channelId}`,
|
||||
channel_id: channelId,
|
||||
attachments: [],
|
||||
},
|
||||
route: {
|
||||
sessionKey: `agent:main:discord:channel:${channelId}`,
|
||||
},
|
||||
baseSessionKey: `agent:main:discord:channel:${channelId}`,
|
||||
messageChannelId: channelId,
|
||||
};
|
||||
return createDiscordPreflightContext(channelId);
|
||||
}
|
||||
|
||||
describe("createDiscordMessageHandler bot-self filter", () => {
|
||||
@ -97,10 +40,10 @@ describe("createDiscordMessageHandler bot-self filter", () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
|
||||
const handler = createDiscordMessageHandler(createHandlerParams());
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
|
||||
await expect(
|
||||
handler(createMessageData(BOT_USER_ID) as never, {} as never),
|
||||
handler(createMessageData(DEFAULT_DISCORD_BOT_USER_ID) as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(preflightDiscordMessageMock).not.toHaveBeenCalled();
|
||||
@ -115,7 +58,7 @@ describe("createDiscordMessageHandler bot-self filter", () => {
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const handler = createDiscordMessageHandler(createHandlerParams());
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
|
||||
await expect(
|
||||
handler(createMessageData("user-456") as never, {} as never),
|
||||
|
||||
@ -27,6 +27,13 @@ type DiscordConfig = NonNullable<
|
||||
type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
|
||||
type DiscordClient = import("@buape/carbon").Client;
|
||||
|
||||
const DEFAULT_CFG = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig;
|
||||
|
||||
function createThreadBinding(
|
||||
overrides?: Partial<
|
||||
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
|
||||
@ -82,6 +89,154 @@ function createPreflightArgs(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createGuildTextClient(channelId: string): DiscordClient {
|
||||
return {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient {
|
||||
return {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === params.threadId) {
|
||||
return {
|
||||
id: params.threadId,
|
||||
type: ChannelType.PublicThread,
|
||||
name: "focus",
|
||||
parentId: params.parentId,
|
||||
ownerId: "owner-1",
|
||||
};
|
||||
}
|
||||
if (channelId === params.parentId) {
|
||||
return {
|
||||
id: params.parentId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
function createGuildEvent(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
author: import("@buape/carbon").Message["author"];
|
||||
message: import("@buape/carbon").Message;
|
||||
}): DiscordMessageEvent {
|
||||
return {
|
||||
channel_id: params.channelId,
|
||||
guild_id: params.guildId,
|
||||
guild: {
|
||||
id: params.guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: params.author,
|
||||
message: params.message,
|
||||
} as unknown as DiscordMessageEvent;
|
||||
}
|
||||
|
||||
function createMessage(params: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
author: {
|
||||
id: string;
|
||||
bot: boolean;
|
||||
username?: string;
|
||||
};
|
||||
mentionedUsers?: Array<{ id: string }>;
|
||||
mentionedEveryone?: boolean;
|
||||
attachments?: Array<Record<string, unknown>>;
|
||||
}): import("@buape/carbon").Message {
|
||||
return {
|
||||
id: params.id,
|
||||
content: params.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: params.channelId,
|
||||
attachments: params.attachments ?? [],
|
||||
mentionedUsers: params.mentionedUsers ?? [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: params.mentionedEveryone ?? false,
|
||||
author: params.author,
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
}
|
||||
|
||||
async function runThreadBoundPreflight(params: {
|
||||
threadId: string;
|
||||
parentId: string;
|
||||
message: import("@buape/carbon").Message;
|
||||
threadBinding: import("../../infra/outbound/session-binding-service.js").SessionBindingRecord;
|
||||
discordConfig: DiscordConfig;
|
||||
registerBindingAdapter?: boolean;
|
||||
}) {
|
||||
if (params.registerBindingAdapter) {
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
listBySession: () => [],
|
||||
resolveByConversation: (ref) =>
|
||||
ref.conversationId === params.threadId ? params.threadBinding : null,
|
||||
});
|
||||
}
|
||||
|
||||
const client = createThreadClient({
|
||||
threadId: params.threadId,
|
||||
parentId: params.parentId,
|
||||
});
|
||||
|
||||
return preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_CFG,
|
||||
discordConfig: params.discordConfig,
|
||||
data: createGuildEvent({
|
||||
channelId: params.threadId,
|
||||
guildId: "guild-1",
|
||||
author: params.message.author,
|
||||
message: params.message,
|
||||
}),
|
||||
client,
|
||||
}),
|
||||
threadBindings: {
|
||||
getByThreadId: (id: string) => (id === params.threadId ? params.threadBinding : undefined),
|
||||
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||
});
|
||||
}
|
||||
|
||||
async function runGuildPreflight(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
message: import("@buape/carbon").Message;
|
||||
discordConfig: DiscordConfig;
|
||||
cfg?: import("../../config/config.js").OpenClawConfig;
|
||||
guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
|
||||
}) {
|
||||
return preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: params.cfg ?? DEFAULT_CFG,
|
||||
discordConfig: params.discordConfig,
|
||||
data: createGuildEvent({
|
||||
channelId: params.channelId,
|
||||
guildId: params.guildId,
|
||||
author: params.message.author,
|
||||
message: params.message,
|
||||
}),
|
||||
client: createGuildTextClient(params.channelId),
|
||||
}),
|
||||
guildEntries: params.guildEntries,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolvePreflightMentionRequirement", () => {
|
||||
it("requires mention when config requires mention and thread is not bound", () => {
|
||||
expect(
|
||||
@ -124,81 +279,26 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
const threadId = "thread-system-1";
|
||||
const parentId = "channel-parent-1";
|
||||
const client = {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === threadId) {
|
||||
return {
|
||||
id: threadId,
|
||||
type: ChannelType.PublicThread,
|
||||
name: "focus",
|
||||
parentId,
|
||||
ownerId: "owner-1",
|
||||
};
|
||||
}
|
||||
if (channelId === parentId) {
|
||||
return {
|
||||
id: parentId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-system-1",
|
||||
channelId: threadId,
|
||||
content:
|
||||
"⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: threadId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "OpenClaw",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
const result = await runThreadBoundPreflight({
|
||||
threadId,
|
||||
parentId,
|
||||
message,
|
||||
threadBinding,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: {
|
||||
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
|
||||
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||
data: {
|
||||
channel_id: threadId,
|
||||
guild_id: "guild-1",
|
||||
guild: {
|
||||
id: "guild-1",
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
} as DiscordConfig,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@ -211,87 +311,26 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
const threadId = "thread-bot-regular-1";
|
||||
const parentId = "channel-parent-regular-1";
|
||||
const client = {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === threadId) {
|
||||
return {
|
||||
id: threadId,
|
||||
type: ChannelType.PublicThread,
|
||||
name: "focus",
|
||||
parentId,
|
||||
ownerId: "owner-1",
|
||||
};
|
||||
}
|
||||
if (channelId === parentId) {
|
||||
return {
|
||||
id: parentId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-bot-regular-1",
|
||||
content: "here is tool output chunk",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: threadId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
content: "here is tool output chunk",
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
listBySession: () => [],
|
||||
resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
const result = await runThreadBoundPreflight({
|
||||
threadId,
|
||||
parentId,
|
||||
message,
|
||||
threadBinding,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: {
|
||||
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
|
||||
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||
data: {
|
||||
channel_id: threadId,
|
||||
guild_id: "guild-1",
|
||||
guild: {
|
||||
id: "guild-1",
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
} as DiscordConfig,
|
||||
registerBindingAdapter: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@ -302,42 +341,17 @@ describe("preflightDiscordMessage", () => {
|
||||
const threadBinding = createThreadBinding();
|
||||
const threadId = "thread-bot-focus";
|
||||
const parentId = "channel-parent-focus";
|
||||
const client = {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === threadId) {
|
||||
return {
|
||||
id: threadId,
|
||||
type: ChannelType.PublicThread,
|
||||
name: "focus",
|
||||
parentId,
|
||||
ownerId: "owner-1",
|
||||
};
|
||||
}
|
||||
if (channelId === parentId) {
|
||||
return {
|
||||
id: parentId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const client = createThreadClient({ threadId, parentId });
|
||||
const message = createMessage({
|
||||
id: "m-bot-1",
|
||||
content: "relay message without mention",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: threadId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
content: "relay message without mention",
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
@ -349,24 +363,17 @@ describe("preflightDiscordMessage", () => {
|
||||
const result = await preflightDiscordMessage(
|
||||
createPreflightArgs({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
...DEFAULT_CFG,
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as DiscordConfig,
|
||||
data: {
|
||||
channel_id: threadId,
|
||||
guild_id: "guild-1",
|
||||
guild: {
|
||||
id: "guild-1",
|
||||
name: "Guild One",
|
||||
},
|
||||
data: createGuildEvent({
|
||||
channelId: threadId,
|
||||
guildId: "guild-1",
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as DiscordMessageEvent,
|
||||
}),
|
||||
client,
|
||||
}),
|
||||
);
|
||||
@ -379,69 +386,24 @@ describe("preflightDiscordMessage", () => {
|
||||
it("drops bot messages without mention when allowBots=mentions", async () => {
|
||||
const channelId = "channel-bot-mentions-off";
|
||||
const guildId = "guild-bot-mentions-off";
|
||||
const client = {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-bot-mentions-off",
|
||||
content: "relay chatter",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
content: "relay chatter",
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {
|
||||
allowBots: "mentions",
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
guild: {
|
||||
id: guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
} as DiscordConfig,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@ -450,69 +412,25 @@ describe("preflightDiscordMessage", () => {
|
||||
it("allows bot messages with explicit mention when allowBots=mentions", async () => {
|
||||
const channelId = "channel-bot-mentions-on";
|
||||
const guildId = "guild-bot-mentions-on";
|
||||
const client = {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-bot-mentions-on",
|
||||
content: "hi <@openclaw-bot>",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId,
|
||||
attachments: [],
|
||||
content: "hi <@openclaw-bot>",
|
||||
mentionedUsers: [{ id: "openclaw-bot" }],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {
|
||||
allowBots: "mentions",
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
guild: {
|
||||
id: guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
} as DiscordConfig,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@ -521,75 +439,29 @@ describe("preflightDiscordMessage", () => {
|
||||
it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => {
|
||||
const channelId = "channel-other-mention-1";
|
||||
const guildId = "guild-other-mention-1";
|
||||
const client = {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-other-mention-1",
|
||||
content: "hello <@999>",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId,
|
||||
attachments: [],
|
||||
content: "hello <@999>",
|
||||
mentionedUsers: [{ id: "999" }],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "Alice",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {} as NonNullable<
|
||||
import("../../config/config.js").OpenClawConfig["channels"]
|
||||
>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {} as DiscordConfig,
|
||||
guildEntries: {
|
||||
[guildId]: {
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
guild: {
|
||||
id: guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@ -598,75 +470,29 @@ describe("preflightDiscordMessage", () => {
|
||||
it("does not drop @everyone messages when ignoreOtherMentions=true", async () => {
|
||||
const channelId = "channel-other-mention-everyone";
|
||||
const guildId = "guild-other-mention-everyone";
|
||||
const client = {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-other-mention-everyone",
|
||||
content: "@everyone heads up",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
content: "@everyone heads up",
|
||||
mentionedEveryone: true,
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "Alice",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {} as NonNullable<
|
||||
import("../../config/config.js").OpenClawConfig["channels"]
|
||||
>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
const result = await runGuildPreflight({
|
||||
channelId,
|
||||
guildId,
|
||||
message,
|
||||
discordConfig: {} as DiscordConfig,
|
||||
guildEntries: {
|
||||
[guildId]: {
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
guild: {
|
||||
id: guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@ -676,74 +502,38 @@ describe("preflightDiscordMessage", () => {
|
||||
it("ignores bot-sent @everyone mentions for detection", async () => {
|
||||
const channelId = "channel-everyone-1";
|
||||
const guildId = "guild-everyone-1";
|
||||
const client = {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
const client = createGuildTextClient(channelId);
|
||||
const message = createMessage({
|
||||
id: "m-everyone-1",
|
||||
content: "@everyone heads up",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
content: "@everyone heads up",
|
||||
mentionedEveryone: true,
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_CFG,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as DiscordConfig,
|
||||
data: createGuildEvent({
|
||||
channelId,
|
||||
guildId,
|
||||
author: message.author,
|
||||
message,
|
||||
}),
|
||||
client,
|
||||
}),
|
||||
guildEntries: {
|
||||
[guildId]: {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
guild_id: guildId,
|
||||
guild: {
|
||||
id: guildId,
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@ -754,24 +544,12 @@ describe("preflightDiscordMessage", () => {
|
||||
transcribeFirstAudioMock.mockResolvedValue("hey openclaw");
|
||||
|
||||
const channelId = "channel-audio-1";
|
||||
const client = {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const client = createGuildTextClient(channelId);
|
||||
|
||||
const message = {
|
||||
const message = createMessage({
|
||||
id: "m-audio-1",
|
||||
content: "",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId,
|
||||
content: "",
|
||||
attachments: [
|
||||
{
|
||||
id: "att-1",
|
||||
@ -780,23 +558,17 @@ describe("preflightDiscordMessage", () => {
|
||||
filename: "voice.ogg",
|
||||
},
|
||||
],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "Alice",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage(
|
||||
createPreflightArgs({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
...DEFAULT_CFG,
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["openclaw"],
|
||||
@ -804,16 +576,12 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {} as DiscordConfig,
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
guild_id: "guild-1",
|
||||
guild: {
|
||||
id: "guild-1",
|
||||
name: "Guild One",
|
||||
},
|
||||
data: createGuildEvent({
|
||||
channelId,
|
||||
guildId: "guild-1",
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as DiscordMessageEvent,
|
||||
}),
|
||||
client,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
import {
|
||||
createDiscordHandlerParams,
|
||||
createDiscordPreflightContext,
|
||||
} from "./message-handler.test-helpers.js";
|
||||
|
||||
const preflightDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
const processDiscordMessageMock = vi.hoisted(() => vi.fn());
|
||||
const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn());
|
||||
type SetStatusFn = (patch: Record<string, unknown>) => void;
|
||||
|
||||
vi.mock("./message-handler.preflight.js", () => ({
|
||||
preflightDiscordMessage: preflightDiscordMessageMock,
|
||||
@ -24,52 +27,6 @@ function createDeferred<T = void>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createHandlerParams(overrides?: {
|
||||
setStatus?: (patch: Record<string, unknown>) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
workerRunTimeoutMs?: number;
|
||||
}) {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord,
|
||||
accountId: "default",
|
||||
token: "test-token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-123",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "off" as const,
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
setStatus: overrides?.setStatus,
|
||||
abortSignal: overrides?.abortSignal,
|
||||
workerRunTimeoutMs: overrides?.workerRunTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageData(messageId: string, channelId = "ch-1") {
|
||||
return {
|
||||
channel_id: channelId,
|
||||
@ -85,25 +42,43 @@ function createMessageData(messageId: string, channelId = "ch-1") {
|
||||
}
|
||||
|
||||
function createPreflightContext(channelId = "ch-1") {
|
||||
return createDiscordPreflightContext(channelId);
|
||||
}
|
||||
|
||||
async function createLifecycleStopScenario(params: {
|
||||
createHandler: (status: SetStatusFn) => {
|
||||
handler: (data: never, opts: never) => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
}) {
|
||||
const runInFlight = createDeferred();
|
||||
processDiscordMessageMock.mockImplementation(async () => {
|
||||
await runInFlight.promise;
|
||||
});
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (contextParams: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(contextParams.data.channel_id),
|
||||
);
|
||||
|
||||
const setStatus = vi.fn<SetStatusFn>();
|
||||
const { handler, stop } = params.createHandler(setStatus);
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => {
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const callsBeforeStop = setStatus.mock.calls.length;
|
||||
stop();
|
||||
|
||||
return {
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
message: {
|
||||
id: `msg-${channelId}`,
|
||||
channel_id: channelId,
|
||||
attachments: [],
|
||||
},
|
||||
setStatus,
|
||||
callsBeforeStop,
|
||||
finish: async () => {
|
||||
runInFlight.resolve();
|
||||
await runInFlight.promise;
|
||||
await Promise.resolve();
|
||||
},
|
||||
message: {
|
||||
id: `msg-${channelId}`,
|
||||
channel_id: channelId,
|
||||
attachments: [],
|
||||
},
|
||||
route: {
|
||||
sessionKey: `agent:main:discord:channel:${channelId}`,
|
||||
},
|
||||
baseSessionKey: `agent:main:discord:channel:${channelId}`,
|
||||
messageChannelId: channelId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -113,7 +88,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
processDiscordMessageMock.mockReset();
|
||||
|
||||
const setStatus = vi.fn();
|
||||
createDiscordMessageHandler(createHandlerParams({ setStatus }));
|
||||
createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
|
||||
|
||||
expect(setStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -142,7 +117,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
);
|
||||
|
||||
const setStatus = vi.fn();
|
||||
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
@ -205,7 +180,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const params = createHandlerParams({ workerRunTimeoutMs: 50 });
|
||||
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
|
||||
const handler = createDiscordMessageHandler(params);
|
||||
|
||||
await expect(
|
||||
@ -256,7 +231,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const params = createHandlerParams({ workerRunTimeoutMs: 0 });
|
||||
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 });
|
||||
const handler = createDiscordMessageHandler(params);
|
||||
|
||||
await expect(
|
||||
@ -305,7 +280,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
|
||||
try {
|
||||
const setStatus = vi.fn();
|
||||
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
|
||||
await expect(
|
||||
handler(createMessageData("m-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
@ -342,67 +317,35 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
|
||||
const runInFlight = createDeferred();
|
||||
processDiscordMessageMock.mockImplementation(async () => {
|
||||
await runInFlight.promise;
|
||||
});
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const setStatus = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
const handler = createDiscordMessageHandler(
|
||||
createHandlerParams({ setStatus, abortSignal: abortController.signal }),
|
||||
);
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({
|
||||
createHandler: (status) => {
|
||||
const abortController = new AbortController();
|
||||
const handler = createDiscordMessageHandler(
|
||||
createDiscordHandlerParams({ setStatus: status, abortSignal: abortController.signal }),
|
||||
);
|
||||
return { handler, stop: () => abortController.abort() };
|
||||
},
|
||||
});
|
||||
|
||||
const callsBeforeAbort = setStatus.mock.calls.length;
|
||||
abortController.abort();
|
||||
|
||||
runInFlight.resolve();
|
||||
await runInFlight.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(setStatus.mock.calls.length).toBe(callsBeforeAbort);
|
||||
await finish();
|
||||
expect(setStatus.mock.calls.length).toBe(callsBeforeStop);
|
||||
});
|
||||
|
||||
it("stops status publishing after handler deactivation", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
|
||||
const runInFlight = createDeferred();
|
||||
processDiscordMessageMock.mockImplementation(async () => {
|
||||
await runInFlight.promise;
|
||||
});
|
||||
preflightDiscordMessageMock.mockImplementation(
|
||||
async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const setStatus = vi.fn();
|
||||
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({
|
||||
createHandler: (status) => {
|
||||
const handler = createDiscordMessageHandler(
|
||||
createDiscordHandlerParams({ setStatus: status }),
|
||||
);
|
||||
return { handler, stop: () => handler.deactivate() };
|
||||
},
|
||||
});
|
||||
|
||||
const callsBeforeDeactivate = setStatus.mock.calls.length;
|
||||
handler.deactivate();
|
||||
|
||||
runInFlight.resolve();
|
||||
await runInFlight.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(setStatus.mock.calls.length).toBe(callsBeforeDeactivate);
|
||||
await finish();
|
||||
expect(setStatus.mock.calls.length).toBe(callsBeforeStop);
|
||||
});
|
||||
|
||||
it("skips queued runs that have not started yet after deactivation", async () => {
|
||||
@ -420,7 +363,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
createPreflightContext(params.data.channel_id),
|
||||
);
|
||||
|
||||
const handler = createDiscordMessageHandler(createHandlerParams());
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => {
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
@ -460,7 +403,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
processedMessageIds.push(ctx.messageId ?? "unknown");
|
||||
});
|
||||
|
||||
const handler = createDiscordMessageHandler(createHandlerParams());
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
|
||||
const sequentialDispatch = (async () => {
|
||||
await handler(createMessageData("m-1") as never, {} as never);
|
||||
@ -499,7 +442,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
);
|
||||
|
||||
const setStatus = vi.fn();
|
||||
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
|
||||
|
||||
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
|
||||
await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
76
src/discord/monitor/message-handler.test-helpers.ts
Normal file
76
src/discord/monitor/message-handler.test-helpers.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import type { createDiscordMessageHandler } from "./message-handler.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export const DEFAULT_DISCORD_BOT_USER_ID = "bot-123";
|
||||
|
||||
export function createDiscordHandlerParams(overrides?: {
|
||||
botUserId?: string;
|
||||
setStatus?: (patch: Record<string, unknown>) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
workerRunTimeoutMs?: number;
|
||||
}): Parameters<typeof createDiscordMessageHandler>[0] {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord,
|
||||
accountId: "default",
|
||||
token: "test-token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: overrides?.botUserId ?? DEFAULT_DISCORD_BOT_USER_ID,
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "off" as const,
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
setStatus: overrides?.setStatus,
|
||||
abortSignal: overrides?.abortSignal,
|
||||
workerRunTimeoutMs: overrides?.workerRunTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordPreflightContext(channelId = "ch-1") {
|
||||
return {
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
message: {
|
||||
id: `msg-${channelId}`,
|
||||
channel_id: channelId,
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
message: {
|
||||
id: `msg-${channelId}`,
|
||||
channel_id: channelId,
|
||||
attachments: [],
|
||||
},
|
||||
route: {
|
||||
sessionKey: `agent:main:discord:channel:${channelId}`,
|
||||
},
|
||||
baseSessionKey: `agent:main:discord:channel:${channelId}`,
|
||||
messageChannelId: channelId,
|
||||
};
|
||||
}
|
||||
@ -87,6 +87,75 @@ function createConfig(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStatusCommand(cfg: OpenClawConfig) {
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
return createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
}
|
||||
|
||||
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: `config:acp:discord:default:${channelId}`,
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
function createDispatchSpy() {
|
||||
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
function expectBoundSessionDispatch(
|
||||
dispatchSpy: ReturnType<typeof createDispatchSpy>,
|
||||
boundSessionKey: string,
|
||||
) {
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
describe("Discord native plugin command dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@ -169,20 +238,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
},
|
||||
],
|
||||
} as OpenClawConfig;
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
const command = createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const command = createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
@ -190,53 +246,14 @@ describe("Discord native plugin command dispatch", () => {
|
||||
guildName: "Ops",
|
||||
});
|
||||
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:discord:default:1478836151241412759",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
setConfiguredBinding(channelId, boundSessionKey);
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
expectBoundSessionDispatch(dispatchSpy, boundSessionKey);
|
||||
});
|
||||
|
||||
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
|
||||
@ -266,71 +283,19 @@ describe("Discord native plugin command dispatch", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
const command = createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const command = createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.DM,
|
||||
channelId,
|
||||
});
|
||||
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({
|
||||
spec: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
},
|
||||
record: {
|
||||
bindingId: "config:acp:discord:default:dm-1",
|
||||
targetSessionKey: boundSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionKey: boundSessionKey,
|
||||
});
|
||||
setConfiguredBinding(channelId, boundSessionKey);
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = vi
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
expectBoundSessionDispatch(dispatchSpy, boundSessionKey);
|
||||
});
|
||||
});
|
||||
|
||||
9
src/gateway/input-allowlist.ts
Normal file
9
src/gateway/input-allowlist.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function normalizeInputHostnameAllowlist(
|
||||
values: string[] | undefined,
|
||||
): string[] | undefined {
|
||||
if (!values || values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
@ -28,6 +28,7 @@ import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { sendJson, setSseHeaders, writeDone } from "./http-common.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
import { resolveGatewayRequestContext } from "./http-utils.js";
|
||||
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
|
||||
|
||||
type OpenAiHttpOptions = {
|
||||
auth: ResolvedGatewayAuth;
|
||||
@ -70,14 +71,6 @@ type ResolvedOpenAiChatCompletionsLimits = {
|
||||
images: InputImageLimits;
|
||||
};
|
||||
|
||||
function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined {
|
||||
if (!values || values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveOpenAiChatCompletionsLimits(
|
||||
config: GatewayHttpChatCompletionsConfig | undefined,
|
||||
): ResolvedOpenAiChatCompletionsLimits {
|
||||
@ -94,7 +87,7 @@ function resolveOpenAiChatCompletionsLimits(
|
||||
: DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES,
|
||||
images: {
|
||||
allowUrl: imageConfig?.allowUrl ?? DEFAULT_OPENAI_IMAGE_LIMITS.allowUrl,
|
||||
urlAllowlist: normalizeHostnameAllowlist(imageConfig?.urlAllowlist),
|
||||
urlAllowlist: normalizeInputHostnameAllowlist(imageConfig?.urlAllowlist),
|
||||
allowedMimes: normalizeMimeList(imageConfig?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES),
|
||||
maxBytes: imageConfig?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES,
|
||||
maxRedirects: imageConfig?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS,
|
||||
|
||||
@ -35,6 +35,7 @@ import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { sendJson, setSseHeaders, writeDone } from "./http-common.js";
|
||||
import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js";
|
||||
import { resolveGatewayRequestContext } from "./http-utils.js";
|
||||
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
|
||||
import {
|
||||
CreateResponseBodySchema,
|
||||
type CreateResponseBody,
|
||||
@ -69,14 +70,6 @@ type ResolvedResponsesLimits = {
|
||||
images: InputImageLimits;
|
||||
};
|
||||
|
||||
function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined {
|
||||
if (!values || values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveResponsesLimits(
|
||||
config: GatewayHttpResponsesConfig | undefined,
|
||||
): ResolvedResponsesLimits {
|
||||
@ -91,11 +84,11 @@ function resolveResponsesLimits(
|
||||
: DEFAULT_MAX_URL_PARTS,
|
||||
files: {
|
||||
...fileLimits,
|
||||
urlAllowlist: normalizeHostnameAllowlist(files?.urlAllowlist),
|
||||
urlAllowlist: normalizeInputHostnameAllowlist(files?.urlAllowlist),
|
||||
},
|
||||
images: {
|
||||
allowUrl: images?.allowUrl ?? true,
|
||||
urlAllowlist: normalizeHostnameAllowlist(images?.urlAllowlist),
|
||||
urlAllowlist: normalizeInputHostnameAllowlist(images?.urlAllowlist),
|
||||
allowedMimes: normalizeMimeList(images?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES),
|
||||
maxBytes: images?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES,
|
||||
maxRedirects: images?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS,
|
||||
|
||||
@ -154,7 +154,12 @@ describe("fetchWithSsrFGuard hardening", () => {
|
||||
"Proxy-Authorization": "Basic c2VjcmV0",
|
||||
Cookie: "session=abc",
|
||||
Cookie2: "legacy=1",
|
||||
"X-Api-Key": "custom-secret",
|
||||
"Private-Token": "private-secret",
|
||||
"X-Trace": "1",
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OpenClaw-Test/1.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -164,7 +169,12 @@ describe("fetchWithSsrFGuard hardening", () => {
|
||||
expect(headers.get("proxy-authorization")).toBeNull();
|
||||
expect(headers.get("cookie")).toBeNull();
|
||||
expect(headers.get("cookie2")).toBeNull();
|
||||
expect(headers.get("x-trace")).toBe("1");
|
||||
expect(headers.get("x-api-key")).toBeNull();
|
||||
expect(headers.get("private-token")).toBeNull();
|
||||
expect(headers.get("x-trace")).toBeNull();
|
||||
expect(headers.get("accept")).toBe("application/json");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(headers.get("user-agent")).toBe("OpenClaw-Test/1.0");
|
||||
await result.release();
|
||||
});
|
||||
|
||||
|
||||
@ -52,12 +52,21 @@ type GuardedFetchPresetOptions = Omit<
|
||||
>;
|
||||
|
||||
const DEFAULT_MAX_REDIRECTS = 3;
|
||||
const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"cookie2",
|
||||
];
|
||||
const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cache-control",
|
||||
"content-language",
|
||||
"content-type",
|
||||
"if-match",
|
||||
"if-modified-since",
|
||||
"if-none-match",
|
||||
"if-unmodified-since",
|
||||
"pragma",
|
||||
"range",
|
||||
"user-agent",
|
||||
]);
|
||||
|
||||
export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions {
|
||||
return { ...params, mode: GUARDED_FETCH_MODE.STRICT };
|
||||
@ -87,9 +96,12 @@ function stripSensitiveHeadersForCrossOriginRedirect(init?: RequestInit): Reques
|
||||
if (!init?.headers) {
|
||||
return init;
|
||||
}
|
||||
const headers = new Headers(init.headers);
|
||||
for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) {
|
||||
headers.delete(header);
|
||||
const incoming = new Headers(init.headers);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of incoming.entries()) {
|
||||
if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js";
|
||||
import { resolveProviderAuths } from "./provider-usage.auth.js";
|
||||
|
||||
describe("resolveProviderAuths key normalization", () => {
|
||||
@ -403,4 +404,76 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]);
|
||||
}, {});
|
||||
});
|
||||
|
||||
it("ignores marker-backed config keys for provider usage auth resolution", async () => {
|
||||
await withSuiteHome(
|
||||
async (home) => {
|
||||
const modelDef = {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1024,
|
||||
maxTokens: 256,
|
||||
};
|
||||
await writeConfig(home, {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimaxi.com",
|
||||
models: [modelDef],
|
||||
apiKey: NON_ENV_SECRETREF_MARKER,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["minimax"],
|
||||
});
|
||||
expect(auths).toEqual([]);
|
||||
},
|
||||
{
|
||||
MINIMAX_API_KEY: undefined,
|
||||
MINIMAX_CODE_PLAN_KEY: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps all-caps plaintext config keys eligible for provider usage auth resolution", async () => {
|
||||
await withSuiteHome(
|
||||
async (home) => {
|
||||
const modelDef = {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1024,
|
||||
maxTokens: 256,
|
||||
};
|
||||
await writeConfig(home, {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimaxi.com",
|
||||
models: [modelDef],
|
||||
apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["minimax"],
|
||||
});
|
||||
expect(auths).toEqual([{ provider: "minimax", token: "ALLCAPS_SAMPLE" }]);
|
||||
},
|
||||
{
|
||||
MINIMAX_API_KEY: undefined,
|
||||
MINIMAX_CODE_PLAN_KEY: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
|
||||
import { getCustomProviderApiKey } from "../agents/model-auth.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@ -103,7 +104,7 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
|
||||
|
||||
const cfg = loadConfig();
|
||||
const key = getCustomProviderApiKey(cfg, params.providerId);
|
||||
if (key) {
|
||||
if (key && !isNonSecretApiKeyMarker(key)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
@ -122,9 +123,17 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
|
||||
return undefined;
|
||||
}
|
||||
if (cred.type === "api_key") {
|
||||
return normalizeSecretInput(cred.key);
|
||||
const key = normalizeSecretInput(cred.key);
|
||||
if (key && !isNonSecretApiKeyMarker(key)) {
|
||||
return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return normalizeSecretInput(cred.token);
|
||||
const token = normalizeSecretInput(cred.token);
|
||||
if (token && !isNonSecretApiKeyMarker(token)) {
|
||||
return token;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveOAuthToken(params: {
|
||||
|
||||
@ -65,9 +65,50 @@ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
|
||||
|
||||
let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents;
|
||||
let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache;
|
||||
type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLineWebhookEvents>[1];
|
||||
|
||||
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
|
||||
|
||||
function createReplayMessageEvent(params: {
|
||||
messageId: string;
|
||||
groupId: string;
|
||||
userId: string;
|
||||
webhookEventId: string;
|
||||
isRedelivery: boolean;
|
||||
}) {
|
||||
return {
|
||||
type: "message",
|
||||
message: { id: params.messageId, type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: params.groupId, userId: params.userId },
|
||||
mode: "active",
|
||||
webhookEventId: params.webhookEventId,
|
||||
deliveryContext: { isRedelivery: params.isRedelivery },
|
||||
} as MessageEvent;
|
||||
}
|
||||
|
||||
function createOpenGroupReplayContext(
|
||||
processMessage: LineWebhookContext["processMessage"],
|
||||
replayCache: ReturnType<typeof createLineWebhookReplayCache>,
|
||||
): Parameters<typeof handleLineWebhookEvents>[1] {
|
||||
return {
|
||||
cfg: { channels: { line: { groupPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "open" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: readAllowFromStoreMock,
|
||||
upsertChannelPairingRequest: upsertPairingRequestMock,
|
||||
@ -377,32 +418,14 @@ describe("handleLineWebhookEvents", () => {
|
||||
|
||||
it("deduplicates replayed webhook events by webhookEventId before processing", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m-replay", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-replay", userId: "user-replay" },
|
||||
mode: "active",
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-replay",
|
||||
groupId: "group-replay",
|
||||
userId: "user-replay",
|
||||
webhookEventId: "evt-replay-1",
|
||||
deliveryContext: { isRedelivery: true },
|
||||
} as MessageEvent;
|
||||
|
||||
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
|
||||
cfg: { channels: { line: { groupPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "open" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache: createLineWebhookReplayCache(),
|
||||
};
|
||||
isRedelivery: true,
|
||||
});
|
||||
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
|
||||
|
||||
await handleLineWebhookEvents([event], context);
|
||||
await handleLineWebhookEvents([event], context);
|
||||
@ -419,32 +442,14 @@ describe("handleLineWebhookEvents", () => {
|
||||
const processMessage = vi.fn(async () => {
|
||||
await firstDone;
|
||||
});
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m-inflight", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-inflight", userId: "user-inflight" },
|
||||
mode: "active",
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-inflight",
|
||||
groupId: "group-inflight",
|
||||
userId: "user-inflight",
|
||||
webhookEventId: "evt-inflight-1",
|
||||
deliveryContext: { isRedelivery: true },
|
||||
} as MessageEvent;
|
||||
|
||||
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
|
||||
cfg: { channels: { line: { groupPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "open" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache: createLineWebhookReplayCache(),
|
||||
};
|
||||
isRedelivery: true,
|
||||
});
|
||||
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
|
||||
|
||||
const firstRun = handleLineWebhookEvents([event], context);
|
||||
await Promise.resolve();
|
||||
@ -464,32 +469,14 @@ describe("handleLineWebhookEvents", () => {
|
||||
const processMessage = vi.fn(async () => {
|
||||
await firstDone;
|
||||
});
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m-inflight-fail", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-inflight", userId: "user-inflight" },
|
||||
mode: "active",
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-inflight-fail",
|
||||
groupId: "group-inflight",
|
||||
userId: "user-inflight",
|
||||
webhookEventId: "evt-inflight-fail-1",
|
||||
deliveryContext: { isRedelivery: true },
|
||||
} as MessageEvent;
|
||||
|
||||
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
|
||||
cfg: { channels: { line: { groupPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "open" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache: createLineWebhookReplayCache(),
|
||||
};
|
||||
isRedelivery: true,
|
||||
});
|
||||
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
|
||||
|
||||
const firstRun = handleLineWebhookEvents([event], context);
|
||||
await Promise.resolve();
|
||||
@ -604,32 +591,14 @@ describe("handleLineWebhookEvents", () => {
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("transient failure"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const event = {
|
||||
type: "message",
|
||||
message: { id: "m-fail-then-retry", type: "text", text: "hello" },
|
||||
replyToken: "reply-token",
|
||||
timestamp: Date.now(),
|
||||
source: { type: "group", groupId: "group-retry", userId: "user-retry" },
|
||||
mode: "active",
|
||||
const event = createReplayMessageEvent({
|
||||
messageId: "m-fail-then-retry",
|
||||
groupId: "group-retry",
|
||||
userId: "user-retry",
|
||||
webhookEventId: "evt-fail-then-retry",
|
||||
deliveryContext: { isRedelivery: false },
|
||||
} as MessageEvent;
|
||||
|
||||
const context: Parameters<typeof handleLineWebhookEvents>[1] = {
|
||||
cfg: { channels: { line: { groupPolicy: "open" } } },
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
tokenSource: "config",
|
||||
config: { groupPolicy: "open" },
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
mediaMaxBytes: 1,
|
||||
processMessage,
|
||||
replayCache: createLineWebhookReplayCache(),
|
||||
};
|
||||
isRedelivery: false,
|
||||
});
|
||||
const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache());
|
||||
|
||||
await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure");
|
||||
await handleLineWebhookEvents([event], context);
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
isSenderAllowed,
|
||||
normalizeAllowFrom,
|
||||
normalizeDmAllowFromWithStore,
|
||||
type NormalizedAllowFrom,
|
||||
} from "./bot-access.js";
|
||||
import {
|
||||
getLineSourceInfo,
|
||||
@ -350,17 +351,15 @@ async function shouldProcessLineEvent(
|
||||
return denied;
|
||||
}
|
||||
}
|
||||
const allowForCommands = effectiveGroupAllow;
|
||||
const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId });
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const rawText = resolveEventRawText(event);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, cfg),
|
||||
});
|
||||
return { allowed: true, commandAuthorized: commandGate.commandAuthorized };
|
||||
return {
|
||||
allowed: true,
|
||||
commandAuthorized: resolveLineCommandAuthorized({
|
||||
cfg,
|
||||
event,
|
||||
senderId,
|
||||
allow: effectiveGroupAllow,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (dmPolicy === "disabled") {
|
||||
@ -386,17 +385,15 @@ async function shouldProcessLineEvent(
|
||||
return denied;
|
||||
}
|
||||
|
||||
const allowForCommands = effectiveDmAllow;
|
||||
const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId });
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const rawText = resolveEventRawText(event);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, cfg),
|
||||
});
|
||||
return { allowed: true, commandAuthorized: commandGate.commandAuthorized };
|
||||
return {
|
||||
allowed: true,
|
||||
commandAuthorized: resolveLineCommandAuthorized({
|
||||
cfg,
|
||||
event,
|
||||
senderId,
|
||||
allow: effectiveDmAllow,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
|
||||
@ -413,6 +410,27 @@ function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveLineCommandAuthorized(params: {
|
||||
cfg: OpenClawConfig;
|
||||
event: MessageEvent | PostbackEvent;
|
||||
senderId?: string;
|
||||
allow: NormalizedAllowFrom;
|
||||
}): boolean {
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
allow: params.allow,
|
||||
senderId: params.senderId,
|
||||
});
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const rawText = resolveEventRawText(params.event);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, params.cfg),
|
||||
});
|
||||
return commandGate.commandAuthorized;
|
||||
}
|
||||
|
||||
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
|
||||
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
|
||||
const message = event.message;
|
||||
|
||||
@ -73,7 +73,27 @@ export function parseFenceSpans(buffer: string): FenceSpan[] {
|
||||
}
|
||||
|
||||
export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined {
|
||||
return spans.find((span) => index > span.start && index < span.end);
|
||||
let low = 0;
|
||||
let high = spans.length - 1;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const span = spans[mid];
|
||||
if (!span) {
|
||||
break;
|
||||
}
|
||||
if (index <= span.start) {
|
||||
high = mid - 1;
|
||||
continue;
|
||||
}
|
||||
if (index >= span.end) {
|
||||
low = mid + 1;
|
||||
continue;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean {
|
||||
|
||||
@ -29,7 +29,10 @@ describe("runCapability deepgram provider options", () => {
|
||||
deepgram: {
|
||||
baseUrl: "https://provider.example",
|
||||
apiKey: "test-key",
|
||||
headers: { "X-Provider": "1" },
|
||||
headers: {
|
||||
"X-Provider": "1",
|
||||
"X-Provider-Managed": "secretref-managed",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
@ -39,7 +42,10 @@ describe("runCapability deepgram provider options", () => {
|
||||
audio: {
|
||||
enabled: true,
|
||||
baseUrl: "https://config.example",
|
||||
headers: { "X-Config": "2" },
|
||||
headers: {
|
||||
"X-Config": "2",
|
||||
"X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN",
|
||||
},
|
||||
providerOptions: {
|
||||
deepgram: {
|
||||
detect_language: true,
|
||||
@ -52,7 +58,10 @@ describe("runCapability deepgram provider options", () => {
|
||||
provider: "deepgram",
|
||||
model: "nova-3",
|
||||
baseUrl: "https://entry.example",
|
||||
headers: { "X-Entry": "3" },
|
||||
headers: {
|
||||
"X-Entry": "3",
|
||||
"X-Entry-Managed": "secretref-managed",
|
||||
},
|
||||
providerOptions: {
|
||||
deepgram: {
|
||||
detectLanguage: false,
|
||||
@ -79,8 +88,11 @@ describe("runCapability deepgram provider options", () => {
|
||||
expect(seenBaseUrl).toBe("https://entry.example");
|
||||
expect(seenHeaders).toMatchObject({
|
||||
"X-Provider": "1",
|
||||
"X-Provider-Managed": "secretref-managed",
|
||||
"X-Config": "2",
|
||||
"X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN",
|
||||
"X-Entry": "3",
|
||||
"X-Entry-Managed": "secretref-managed",
|
||||
});
|
||||
expect(seenQuery).toMatchObject({
|
||||
detect_language: false,
|
||||
|
||||
@ -40,6 +40,26 @@ import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
|
||||
|
||||
export type ProviderRegistry = Map<string, MediaUnderstandingProvider>;
|
||||
|
||||
function sanitizeProviderHeaders(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
// Intentionally preserve marker-shaped values here. This path handles
|
||||
// explicit config/runtime provider headers, where literal values may
|
||||
// legitimately match marker patterns; discovered models.json entries are
|
||||
// sanitized separately in the model registry path.
|
||||
next[key] = value;
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function trimOutput(text: string, maxChars?: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (!maxChars || trimmed.length <= maxChars) {
|
||||
@ -352,9 +372,9 @@ async function resolveProviderExecutionContext(params: {
|
||||
});
|
||||
const baseUrl = params.entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl;
|
||||
const mergedHeaders = {
|
||||
...providerConfig?.headers,
|
||||
...params.config?.headers,
|
||||
...params.entry.headers,
|
||||
...sanitizeProviderHeaders(providerConfig?.headers as Record<string, unknown> | undefined),
|
||||
...sanitizeProviderHeaders(params.config?.headers as Record<string, unknown> | undefined),
|
||||
...sanitizeProviderHeaders(params.entry.headers as Record<string, unknown> | undefined),
|
||||
};
|
||||
const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
|
||||
return { apiKeys, baseUrl, headers };
|
||||
|
||||
@ -61,6 +61,7 @@ export {
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
export { buildSecretInputSchema } from "./secret-input-schema.js";
|
||||
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
|
||||
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
|
||||
export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user