diff --git a/.detect-secrets.cfg b/.detect-secrets.cfg index e40a4a1689e..3ab7ebb69b5 100644 --- a/.detect-secrets.cfg +++ b/.detect-secrets.cfg @@ -26,3 +26,18 @@ pattern = === "string" pattern = typeof remote\?\.password === "string" # Docker apt signing key fingerprint constant; not a secret. pattern = OPENCLAW_DOCKER_GPG_FINGERPRINT= +# Credential matrix metadata field in docs JSON; not a secret value. +pattern = "secretShape": "(secret_input|sibling_ref)" +# Docs line describing API key rotation knobs; not a credential. +pattern = API key rotation \(provider-specific\): set `\*_API_KEYS` +# Docs line describing remote password precedence; not a credential. +pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.auth\.passw[o]rd` -> `gateway\.remote\.passw[o]rd` +pattern = passw[o]rd: `OPENCLAW_GATEWAY_PASSW[O]RD` -> `gateway\.remote\.passw[o]rd` -> `gateway\.auth\.passw[o]rd` +# Test fixture starts a multiline fake private key; detector should ignore the header line. +pattern = const key = `-----BEGIN PRIVATE KEY----- +# Docs examples: literal placeholder API key snippets and shell heredoc helper. +pattern = export CUSTOM_API_K[E]Y="your-key" +pattern = grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \|\| cat >> ~/.bashrc <<'EOF' +pattern = env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \}, +pattern = "ap[i]Key": "xxxxx", +pattern = ap[i]Key: "A[I]za\.\.\.", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8850f9f53e2..6fe09412fab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,9 +303,21 @@ jobs: install-deps: "false" - name: Setup Python + id: setup-python uses: actions/setup-python@v5 with: python-version: "3.12" + cache: "pip" + cache-dependency-path: | + pyproject.toml + .pre-commit-config.yaml + .github/workflows/ci.yml + + - name: Restore pre-commit cache + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} - name: Install pre-commit run: | @@ -449,9 +461,11 @@ jobs: cache-key-suffix: "node22" # Sticky disk mount currently retries/fails on every shard and adds ~50s # before install while still yielding zero pnpm store reuse. + # Try exact-key actions/cache restores instead to recover store reuse + # without the sticky-disk mount penalty. use-sticky-disk: "false" use-restore-keys: "false" - use-actions-cache: "false" + use-actions-cache: "true" - name: Runtime versions run: | @@ -470,7 +484,9 @@ jobs: which node node -v pnpm -v - pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + # Persist Windows-native postinstall outputs in the pnpm store so restored + # caches can skip repeated rebuild/download work on later shards/runs. + pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true - name: Configure test shard (Windows) if: matrix.task == 'test' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 296660d1014..6fcc25e7279 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,26 @@ repos: - 'typeof remote\?\.password === "string"' - --exclude-lines - "OPENCLAW_DOCKER_GPG_FINGERPRINT=" + - --exclude-lines + - '"secretShape": "(secret_input|sibling_ref)"' + - --exclude-lines + - 'API key rotation \(provider-specific\): set `\*_API_KEYS`' + - --exclude-lines + - 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.auth\.password` -> `gateway\.remote\.password`' + - --exclude-lines + - 'password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\.remote\.password` -> `gateway\.auth\.password`' + - --exclude-files + - '^src/gateway/client\.watchdog\.test\.ts$' + - --exclude-lines + - 'export CUSTOM_API_K[E]Y="your-key"' + - --exclude-lines + - 'grep -q ''N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache'' ~/.bashrc \|\| cat >> ~/.bashrc <<''EOF''' + - --exclude-lines + - 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},' + - --exclude-lines + - '"ap[i]Key": "xxxxx",' + - --exclude-lines + - 'ap[i]Key: "A[I]za\.\.\.",' # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 diff --git a/.secrets.baseline b/.secrets.baseline index 8066ff84714..fe52d22d043 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -128,7 +128,8 @@ { "path": "detect_secrets.filters.regex.should_exclude_file", "pattern": [ - "(^|/)pnpm-lock\\.yaml$" + "(^|/)pnpm-lock\\.yaml$", + "^src/gateway/client\\.watchdog\\.test\\.ts$" ] }, { @@ -142,8 +143,23 @@ "\"talk\\.apiKey\"", "=== \"string\"", "typeof remote\\?\\.password === \"string\"", - "OPENCLAW_DOCKER_GPG_FINGERPRINT=" + "OPENCLAW_DOCKER_GPG_FINGERPRINT=", + "\"secretShape\": \"(secret_input|sibling_ref)\"", + "API key rotation \\(provider-specific\\): set `\\*_API_KEYS`", + "password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\\.auth\\.password` -> `gateway\\.remote\\.password`", + "password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway\\.remote\\.password` -> `gateway\\.auth\\.password`", + "export CUSTOM_API_K[E]Y=\"your-key\"", + "grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'", + "env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},", + "\"ap[i]Key\": \"xxxxx\",", + "ap[i]Key: \"A[I]za\\.\\.\\.\"," ] + }, + { + "path": "src/gateway/client\\.watchdog\\.test\\.ts$", + "reason": "Allowlisted because this is a static PEM fixture used by the watchdog TLS fingerprint test.", + "min_level": 2, + "condition": "filename" } ], "results": { @@ -163,10 +179,10 @@ "line_number": 15 } ], - "apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt": [ + "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [ { "type": "Hex High Entropy String", - "filename": "apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt", + "filename": "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt", "hashed_secret": "ee662f2bc691daa48d074542722d8e1b0587673c", "is_verified": false, "line_number": 58 @@ -190,6 +206,22 @@ "line_number": 1745 } ], + "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ + { + "type": "Secret Keyword", + "filename": "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift", + "hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190", + "is_verified": false, + "line_number": 26 + }, + { + "type": "Secret Keyword", + "filename": "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift", + "hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689", + "is_verified": false, + "line_number": 42 + } + ], "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift": [ { "type": "Secret Keyword", @@ -9690,15 +9722,6 @@ "line_number": 417 } ], - "docs/design/kilo-gateway-integration.md": [ - { - "type": "Secret Keyword", - "filename": "docs/design/kilo-gateway-integration.md", - "hashed_secret": "9addbf544119efa4a64223b649750a510f0d463f", - "is_verified": false, - "line_number": 458 - } - ], "docs/gateway/configuration-examples.md": [ { "type": "Secret Keyword", @@ -9777,35 +9800,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": [ @@ -9840,22 +9863,6 @@ "line_number": 124 } ], - "docs/gateway/remote.md": [ - { - "type": "Secret Keyword", - "filename": "docs/gateway/remote.md", - "hashed_secret": "7d852a6979e11c7a40c35c63a2ee96edb2dc2c69", - "is_verified": false, - "line_number": 111 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/remote.md", - "hashed_secret": "e1ce9e0c459c8ef30dcadf6fc4e2d50f63a7aa8a", - "is_verified": false, - "line_number": 114 - } - ], "docs/gateway/tailscale.md": [ { "type": "Secret Keyword", @@ -9918,15 +9925,6 @@ "line_number": 2489 } ], - "docs/help/testing.md": [ - { - "type": "Secret Keyword", - "filename": "docs/help/testing.md", - "hashed_secret": "e008bed242a21b8279c220f84ba16019a67a9dd4", - "is_verified": false, - "line_number": 94 - } - ], "docs/install/macos-vm.md": [ { "type": "Secret Keyword", @@ -10022,15 +10020,6 @@ "line_number": 149 } ], - "docs/providers/mistral.md": [ - { - "type": "Secret Keyword", - "filename": "docs/providers/mistral.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 27 - } - ], "docs/providers/moonshot.md": [ { "type": "Secret Keyword", @@ -10144,31 +10133,6 @@ "line_number": 27 } ], - "docs/reference/secretref-user-supplied-credentials-matrix.json": [ - { - "type": "Secret Keyword", - "filename": "docs/reference/secretref-user-supplied-credentials-matrix.json", - "hashed_secret": "d6c8cbcbe34bf0e02cf1a52e27afcf18b59b3f79", - "is_verified": false, - "line_number": 22 - }, - { - "type": "Secret Keyword", - "filename": "docs/reference/secretref-user-supplied-credentials-matrix.json", - "hashed_secret": "e9a292f7f4d25b0d861458719c6115de3ec813c3", - "is_verified": false, - "line_number": 40 - } - ], - "docs/start/wizard-cli-automation.md": [ - { - "type": "Secret Keyword", - "filename": "docs/start/wizard-cli-automation.md", - "hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3", - "is_verified": false, - "line_number": 155 - } - ], "docs/tools/browser.md": [ { "type": "Basic Auth Credentials", @@ -10213,20 +10177,6 @@ "is_verified": false, "line_number": 90 }, - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "4a9fd550cf205ab06ee932f41a132ff53cb83d83", - "is_verified": false, - "line_number": 107 - }, - { - "type": "Secret Keyword", - "filename": "docs/tools/web.md", - "hashed_secret": "1ccebc9638f47c80fc388173e346b2fa51178cca", - "is_verified": false, - "line_number": 135 - }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", @@ -10258,15 +10208,6 @@ "line_number": 101 } ], - "docs/vps.md": [ - { - "type": "Base64 High Entropy String", - "filename": "docs/vps.md", - "hashed_secret": "66eba27d45030064a428078cf4d510002a445f27", - "is_verified": false, - "line_number": 60 - } - ], "docs/zh-CN/brave-search.md": [ { "type": "Secret Keyword", @@ -10927,36 +10868,6 @@ "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, "line_number": 169 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.test.ts", - "hashed_secret": "891f33ddd2af62f77eab3b7aac8d4874acc093e4", - "is_verified": false, - "line_number": 2394 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.test.ts", - "hashed_secret": "01ee85f364fd0a345244d10a59d73b9f28b2e8da", - "is_verified": false, - "line_number": 2398 - } - ], - "extensions/bluebubbles/src/monitor.webhook-auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.webhook-auth.test.ts", - "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", - "is_verified": false, - "line_number": 169 - }, - { - "type": "Secret Keyword", - "filename": "extensions/bluebubbles/src/monitor.webhook-auth.test.ts", - "hashed_secret": "1ae0af3fe72b3ba394f9fa95a6cffc090d726c23", - "is_verified": false, - "line_number": 490 } ], "extensions/bluebubbles/src/reactions.test.ts": [ @@ -11023,22 +10934,6 @@ "line_number": 9 } ], - "extensions/diagnostics-otel/src/service.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "extensions/diagnostics-otel/src/service.test.ts", - "hashed_secret": "e6aa9dc072fcb9dbe42761f25c976143c39d3deb", - "is_verified": false, - "line_number": 332 - }, - { - "type": "Base64 High Entropy String", - "filename": "extensions/diagnostics-otel/src/service.test.ts", - "hashed_secret": "7e634f2e8cbddf340740ee856bf272aaa6d6d770", - "is_verified": false, - "line_number": 352 - } - ], "extensions/feishu/skills/feishu-doc/SKILL.md": [ { "type": "Hex High Entropy String", @@ -11057,66 +10952,6 @@ "line_number": 40 } ], - "extensions/feishu/src/accounts.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/accounts.test.ts", - "hashed_secret": "e066a1720c6745f87bad43d4dc1206a6beaf4298", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/accounts.test.ts", - "hashed_secret": "32db07403e892e96ab02693d38bffb2777e82c94", - "is_verified": false, - "line_number": 20 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/accounts.test.ts", - "hashed_secret": "b72c7c889dbb48caa14157494693a442309d9f08", - "is_verified": false, - "line_number": 51 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/accounts.test.ts", - "hashed_secret": "d15b430d272b72b4149afe9098236dd161888d76", - "is_verified": false, - "line_number": 167 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/accounts.test.ts", - "hashed_secret": "ea45a4958bbb18451e1d48aa90745cb35a508b29", - "is_verified": false, - "line_number": 239 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/accounts.test.ts", - "hashed_secret": "3017efcbcc4d30831b27c2793bac8e7ea61c905a", - "is_verified": false, - "line_number": 254 - } - ], - "extensions/feishu/src/bot.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/bot.test.ts", - "hashed_secret": "6ccf7c8dbcc240973f7793b6bbc8f1d5e6efd4b1", - "is_verified": false, - "line_number": 1091 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/bot.test.ts", - "hashed_secret": "1962fc9032fed7c415a657282d617ba80e82f884", - "is_verified": false, - "line_number": 1154 - } - ], "extensions/feishu/src/channel.test.ts": [ { "type": "Secret Keyword", @@ -11126,133 +10961,6 @@ "line_number": 21 } ], - "extensions/feishu/src/chat.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/chat.test.ts", - "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", - "is_verified": false, - "line_number": 32 - } - ], - "extensions/feishu/src/client.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "2e8a3d5cbfeb3818c59b66a9f0bf3b80990489f3", - "is_verified": false, - "line_number": 62 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "cfc5057763ea7dabd5c6f7325c0d39c9b8d1baf1", - "is_verified": false, - "line_number": 105 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "8636f9964c42d12b2d698204e426276c41df66d1", - "is_verified": false, - "line_number": 113 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "2e59eff806170ad50c34e3372faef694874fae93", - "is_verified": false, - "line_number": 135 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "f4e4e5f8d09c24c2863cceca031e94154a63e138", - "is_verified": false, - "line_number": 154 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "e55783e61a4f2ae1efd1d1ccb142c902c473ef86", - "is_verified": false, - "line_number": 176 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "67db48d9a41265dfca56d8b198f3e28ee9b6bbcb", - "is_verified": false, - "line_number": 200 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "b8d75c4b958af69d9be3c2efa450e7c4a1b41770", - "is_verified": false, - "line_number": 222 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "f546848b2bf72fec2651db6b80e5592fda678e2f", - "is_verified": false, - "line_number": 245 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/client.test.ts", - "hashed_secret": "c7c5ddbf5e808a49ef38791caf8563c0bc0da434", - "is_verified": false, - "line_number": 264 - } - ], - "extensions/feishu/src/config-schema.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/config-schema.test.ts", - "hashed_secret": "d25db33e5c07ac669f08da0adc2bde73b15ee929", - "is_verified": false, - "line_number": 39 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/config-schema.test.ts", - "hashed_secret": "8437d84cae482d10a2b9fd3f555d45006979e4be", - "is_verified": false, - "line_number": 67 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/config-schema.test.ts", - "hashed_secret": "32db07403e892e96ab02693d38bffb2777e82c94", - "is_verified": false, - "line_number": 174 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/config-schema.test.ts", - "hashed_secret": "2bd27e71d7e14bbd5ac1576290ed6074dc450b5a", - "is_verified": false, - "line_number": 185 - } - ], - "extensions/feishu/src/docx.account-selection.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/docx.account-selection.test.ts", - "hashed_secret": "db2b80fd220b75be76e698a9164f989baf731caf", - "is_verified": false, - "line_number": 30 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/docx.account-selection.test.ts", - "hashed_secret": "57cb5f8d57e1a3c1bcf90d73e103af6a775591a6", - "is_verified": false, - "line_number": 31 - } - ], "extensions/feishu/src/docx.test.ts": [ { "type": "Secret Keyword", @@ -11271,82 +10979,6 @@ "line_number": 76 } ], - "extensions/feishu/src/monitor.webhook-security.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/monitor.webhook-security.test.ts", - "hashed_secret": "cf27add3cb4cb83efe9a48cf7289068fa869c4cd", - "is_verified": false, - "line_number": 76 - } - ], - "extensions/feishu/src/onboarding.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/onboarding.test.ts", - "hashed_secret": "2e8a3d5cbfeb3818c59b66a9f0bf3b80990489f3", - "is_verified": false, - "line_number": 64 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/onboarding.test.ts", - "hashed_secret": "d5fc216f56ec5ef58691c854104ba78667d9efad", - "is_verified": false, - "line_number": 78 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/onboarding.test.ts", - "hashed_secret": "d819cf9769641b789fc8f539e0cd8cbe5606e057", - "is_verified": false, - "line_number": 82 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/onboarding.test.ts", - "hashed_secret": "72b6d12b3e7034420015375375466c37ec68be51", - "is_verified": false, - "line_number": 114 - } - ], - "extensions/feishu/src/probe.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/probe.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 37 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/probe.test.ts", - "hashed_secret": "640d87e741e6aa4c669a82a4cd304787960513ab", - "is_verified": false, - "line_number": 195 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/probe.test.ts", - "hashed_secret": "4205714cdfe14ed9e3d030ddf7887781b964f510", - "is_verified": false, - "line_number": 199 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/probe.test.ts", - "hashed_secret": "5a718c07b29bb4cd5fafb4a3ad377efc2dad9a59", - "is_verified": false, - "line_number": 214 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/probe.test.ts", - "hashed_secret": "5da0807f9682b03d10b7906c5d2312d46368500c", - "is_verified": false, - "line_number": 219 - } - ], "extensions/feishu/src/reply-dispatcher.test.ts": [ { "type": "Secret Keyword", @@ -11356,20 +10988,13 @@ "line_number": 74 } ], - "extensions/feishu/src/tool-account-routing.test.ts": [ + "extensions/google-antigravity-auth/index.ts": [ { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/tool-account-routing.test.ts", - "hashed_secret": "db2b80fd220b75be76e698a9164f989baf731caf", + "type": "Base64 High Entropy String", + "filename": "extensions/google-antigravity-auth/index.ts", + "hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f", "is_verified": false, - "line_number": 38 - }, - { - "type": "Secret Keyword", - "filename": "extensions/feishu/src/tool-account-routing.test.ts", - "hashed_secret": "57cb5f8d57e1a3c1bcf90d73e103af6a775591a6", - "is_verified": false, - "line_number": 43 + "line_number": 14 } ], "extensions/google-gemini-cli-auth/oauth.test.ts": [ @@ -11379,31 +11004,6 @@ "hashed_secret": "021343c1f561d7bcbc3b513df45cc3a6baf67b43", "is_verified": false, "line_number": 43 - }, - { - "type": "Secret Keyword", - "filename": "extensions/google-gemini-cli-auth/oauth.test.ts", - "hashed_secret": "07d1db7c4a73c573d6d038b3d26194a7957c513c", - "is_verified": false, - "line_number": 311 - } - ], - "extensions/googlechat/src/api.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "extensions/googlechat/src/api.test.ts", - "hashed_secret": "bc7bd07bb0114ca5928ca561817efc6cd7083966", - "is_verified": false, - "line_number": 84 - } - ], - "extensions/googlechat/src/channel.outbound.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/googlechat/src/channel.outbound.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 50 } ], "extensions/irc/src/accounts.ts": [ @@ -11474,29 +11074,6 @@ "line_number": 8 } ], - "extensions/mattermost/src/normalize.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/mattermost/src/normalize.test.ts", - "hashed_secret": "713ecccd228f49a6068bedd7a64510b50b4284e5", - "is_verified": false, - "line_number": 77 - }, - { - "type": "Base64 High Entropy String", - "filename": "extensions/mattermost/src/normalize.test.ts", - "hashed_secret": "a8e2493e7579ba630d56b2552d5fd2a7198ad943", - "is_verified": false, - "line_number": 82 - }, - { - "type": "Base64 High Entropy String", - "filename": "extensions/mattermost/src/normalize.test.ts", - "hashed_secret": "9a33401dd4f9784482d2db77bbe93d99cea1a571", - "is_verified": false, - "line_number": 94 - } - ], "extensions/memory-lancedb/config.ts": [ { "type": "Secret Keyword", @@ -11515,15 +11092,6 @@ "line_number": 71 } ], - "extensions/msteams/src/monitor.lifecycle.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/msteams/src/monitor.lifecycle.test.ts", - "hashed_secret": "5a21585c3dfc2797afe4634fa150d996f4ef5b5e", - "is_verified": false, - "line_number": 143 - } - ], "extensions/msteams/src/probe.test.ts": [ { "type": "Secret Keyword", @@ -11533,15 +11101,6 @@ "line_number": 35 } ], - "extensions/msteams/src/token.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/msteams/src/token.test.ts", - "hashed_secret": "5a21585c3dfc2797afe4634fa150d996f4ef5b5e", - "is_verified": false, - "line_number": 38 - } - ], "extensions/nextcloud-talk/src/accounts.ts": [ { "type": "Secret Keyword", @@ -11558,22 +11117,6 @@ "line_number": 169 } ], - "extensions/nextcloud-talk/src/channel.startup.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/nextcloud-talk/src/channel.startup.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 24 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nextcloud-talk/src/channel.startup.test.ts", - "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", - "is_verified": false, - "line_number": 25 - } - ], "extensions/nextcloud-talk/src/channel.ts": [ { "type": "Secret Keyword", @@ -11583,15 +11126,6 @@ "line_number": 399 } ], - "extensions/nextcloud-talk/src/send.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/nextcloud-talk/src/send.test.ts", - "hashed_secret": "dbdab9be92cacdae6a97e8601332bfaa8545800f", - "is_verified": false, - "line_number": 11 - } - ], "extensions/nostr/README.md": [ { "type": "Secret Keyword", @@ -11601,36 +11135,6 @@ "line_number": 46 } ], - "extensions/nostr/src/channel.outbound.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/channel.outbound.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 54 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nostr/src/channel.outbound.test.ts", - "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", - "is_verified": false, - "line_number": 54 - }, - { - "type": "Hex High Entropy String", - "filename": "extensions/nostr/src/channel.outbound.test.ts", - "hashed_secret": "e8b2cccf31904f5d9c62838922648cfeaa4c07e0", - "is_verified": false, - "line_number": 55 - }, - { - "type": "Secret Keyword", - "filename": "extensions/nostr/src/channel.outbound.test.ts", - "hashed_secret": "44682b9fe21c229330c1e5cf9c414d4267d97719", - "is_verified": false, - "line_number": 66 - } - ], "extensions/nostr/src/channel.test.ts": [ { "type": "Hex High Entropy String", @@ -11773,38 +11277,6 @@ "line_number": 200 } ], - "extensions/slack/src/channel.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/slack/src/channel.test.ts", - "hashed_secret": "514f52b114ae97e309055b6f419798569dc48a2b", - "is_verified": false, - "line_number": 147 - }, - { - "type": "Secret Keyword", - "filename": "extensions/slack/src/channel.test.ts", - "hashed_secret": "071d3673192b4b44a84aa73ac9d00c155821303b", - "is_verified": false, - "line_number": 217 - }, - { - "type": "Secret Keyword", - "filename": "extensions/slack/src/channel.test.ts", - "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", - "is_verified": false, - "line_number": 219 - } - ], - "extensions/telegram/src/channel.test.ts": [ - { - "type": "Secret Keyword", - "filename": "extensions/telegram/src/channel.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 132 - } - ], "extensions/twitch/src/onboarding.test.ts": [ { "type": "Secret Keyword", @@ -11893,181 +11365,82 @@ "line_number": 22 } ], - "src/acp/client.test.ts": [ + "src/agents/compaction.tool-result-details.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/acp/client.test.ts", - "hashed_secret": "d862c48593628a39a76daafde56f16b69eddd7c2", - "is_verified": false, - "line_number": 69 - }, - { - "type": "Secret Keyword", - "filename": "src/acp/client.test.ts", - "hashed_secret": "aac1281207c0f83f113d70cd1200bd86ce30ffcb", - "is_verified": false, - "line_number": 70 - }, - { - "type": "Secret Keyword", - "filename": "src/acp/client.test.ts", - "hashed_secret": "787951939f82ab64286006ce2a430e06c6d54086", - "is_verified": false, - "line_number": 71 - }, - { - "type": "Secret Keyword", - "filename": "src/acp/client.test.ts", - "hashed_secret": "d503c694c0e762d786079a3f8bd6df32de508a9b", - "is_verified": false, - "line_number": 85 - }, - { - "type": "Secret Keyword", - "filename": "src/acp/client.test.ts", - "hashed_secret": "0d8c5e792dc079c912039086e892330076db8129", - "is_verified": false, - "line_number": 98 - } - ], - "src/acp/server.startup.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/acp/server.startup.test.ts", - "hashed_secret": "60fe331dc434ac211c53f33da22a384aa0e3fec5", - "is_verified": false, - "line_number": 183 - } - ], - "src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts", - "hashed_secret": "02ecb94373bfb3dfe827ca18409f50b016e8302a", - "is_verified": false, - "line_number": 26 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts", - "hashed_secret": "f8ca0d7266886f4b5be9adddc9b66017b3bf1a4b", - "is_verified": false, - "line_number": 27 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts", - "hashed_secret": "0775624b6a8da2aaf29e334372656c1b657c21b7", - "is_verified": false, - "line_number": 94 - } - ], - "src/agents/compaction.tool-result-details.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/compaction.tool-result-details.test.ts", + "filename": "src/agents/compaction.tool-result-details.e2e.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 57 + "line_number": 50 } ], - "src/agents/memory-search.test.ts": [ + "src/agents/memory-search.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/memory-search.test.ts", + "filename": "src/agents/memory-search.e2e.test.ts", "hashed_secret": "a1b49d68a91fdf9c9217773f3fac988d77fa0f50", "is_verified": false, - "line_number": 191 + "line_number": 189 } ], - "src/agents/minimax-vlm.normalizes-api-key.test.ts": [ + "src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/minimax-vlm.normalizes-api-key.test.ts", + "filename": "src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts", "hashed_secret": "8a8461b67e3fe515f248ac2610fd7b1f4fc3b412", "is_verified": false, - "line_number": 29 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/minimax-vlm.normalizes-api-key.test.ts", - "hashed_secret": "bcdec29c5e1ade0fc995c3a18862f0111e51a998", - "is_verified": false, - "line_number": 56 + "line_number": 28 } ], - "src/agents/model-auth-label.test.ts": [ - { - "type": "GitHub Token", - "filename": "src/agents/model-auth-label.test.ts", - "hashed_secret": "e175c6f5f2a92e8623bd9a4820edb4e8c1b0fd10", - "is_verified": false, - "line_number": 35 - }, + "src/agents/model-auth.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/model-auth-label.test.ts", - "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", - "is_verified": false, - "line_number": 55 - } - ], - "src/agents/model-auth.profiles.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "07a6b9cec637c806195e8aa7e5c0851ab03dc35e", "is_verified": false, - "line_number": 194 + "line_number": 228 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "21f296583ccd80c5ab9b3330a8b0d47e4a409fb9", "is_verified": false, - "line_number": 208 + "line_number": 254 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "b65888424ecafcc98bfd803b24817e4dadf821f8", "is_verified": false, - "line_number": 219 + "line_number": 275 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", - "hashed_secret": "b17453920671d0cb8a415b649a066b3df3d36fb0", - "is_verified": false, - "line_number": 253 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", "is_verified": false, - "line_number": 286 + "line_number": 296 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "dff6d4ff5dc357cf451d1855ab9cbda562645c9f", "is_verified": false, - "line_number": 301 + "line_number": 319 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "b43be360db55d89ec6afd74d6ed8f82002fe4982", "is_verified": false, - "line_number": 333 + "line_number": 374 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.profiles.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "5b850e9dc678446137ff6d905ebd78634d687fdd", "is_verified": false, - "line_number": 344 + "line_number": 395 } ], "src/agents/model-auth.ts": [ @@ -12076,23 +11449,7 @@ "filename": "src/agents/model-auth.ts", "hashed_secret": "8956265d216d474a080edaa97880d37fc1386f33", "is_verified": false, - "line_number": 25 - } - ], - "src/agents/model-fallback.run-embedded.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/model-fallback.run-embedded.e2e.test.ts", - "hashed_secret": "845fa28a5bf5d82cfa91a00ef9cf6cca8aef00db", - "is_verified": false, - "line_number": 111 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/model-fallback.run-embedded.e2e.test.ts", - "hashed_secret": "19e506a6fcda111778646087fb7aad7f00267113", - "is_verified": false, - "line_number": 127 + "line_number": 27 } ], "src/agents/models-config.e2e-harness.ts": [ @@ -12104,93 +11461,29 @@ "line_number": 130 } ], - "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts": [ + "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", - "hashed_secret": "2a9da819718779deba96d5aee1d1f4948047c2bd", + "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts", + "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", "is_verified": false, - "line_number": 46 + "line_number": 19 }, { "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 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", + "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts", "hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7", "is_verified": false, - "line_number": 181 - }, - { - "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": 73 } ], - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": [ + "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", + "filename": "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts", "hashed_secret": "980d02eb9335ae7c9e9984f6c8ad432352a0d2ac", "is_verified": false, - "line_number": 17 - } - ], - "src/agents/models-config.providers.google-antigravity.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.google-antigravity.test.ts", - "hashed_secret": "65ef0bf81fc443b3e15a494151196f38c8273c96", - "is_verified": false, - "line_number": 27 - } - ], - "src/agents/models-config.providers.kilocode.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.kilocode.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 24 - } - ], - "src/agents/models-config.providers.kimi-coding.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.kimi-coding.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 12 - } - ], - "src/agents/models-config.providers.normalize-keys.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.normalize-keys.test.ts", - "hashed_secret": "ba4d38e2a7e8c718913887136d2526351d05cd69", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.normalize-keys.test.ts", - "hashed_secret": "02ecb94373bfb3dfe827ca18409f50b016e8302a", - "is_verified": false, - "line_number": 46 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.normalize-keys.test.ts", - "hashed_secret": "b9cdfe69a75e4f2491bcbaf1934ab5e4fd69eb6b", - "is_verified": false, - "line_number": 52 + "line_number": 20 } ], "src/agents/models-config.providers.nvidia.test.ts": [ @@ -12209,51 +11502,35 @@ "line_number": 22 } ], - "src/agents/models-config.providers.ollama.test.ts": [ + "src/agents/models-config.providers.ollama.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.ollama.test.ts", + "filename": "src/agents/models-config.providers.ollama.e2e.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 54 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.ollama.test.ts", - "hashed_secret": "3148ad4aafbeefee82355e1cde29b6d77ba4cf21", - "is_verified": false, - "line_number": 248 + "line_number": 37 } ], - "src/agents/models-config.providers.qianfan.test.ts": [ + "src/agents/models-config.providers.qianfan.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.qianfan.test.ts", + "filename": "src/agents/models-config.providers.qianfan.e2e.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 11 + "line_number": 12 } ], - "src/agents/models-config.providers.volcengine-byteplus.test.ts": [ + "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.providers.volcengine-byteplus.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 13 - } - ], - "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts", + "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts", "hashed_secret": "4c7bac93427c83bcc3beeceebfa54f16f801b78f", "is_verified": false, "line_number": 100 }, { "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts", + "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts", "hashed_secret": "4f2b3ddc953da005a97d825652080fe6eff21520", "is_verified": false, "line_number": 113 @@ -12268,38 +11545,6 @@ "line_number": 92 } ], - "src/agents/owner-display.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/owner-display.test.ts", - "hashed_secret": "e9dc4e431a9043d0d7d2750af1189e77e2834877", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/owner-display.test.ts", - "hashed_secret": "d9d2f263c630f79c8eb176dbccfef7c3ade3ddcc", - "is_verified": false, - "line_number": 70 - } - ], - "src/agents/pi-embedded-runner-extraparams.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner-extraparams.test.ts", - "hashed_secret": "4604122d2d19b953716499c7fade74e3db0ad17f", - "is_verified": false, - "line_number": 1075 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner-extraparams.test.ts", - "hashed_secret": "81181bf462a0965325a629cff91f511e285d59d4", - "is_verified": false, - "line_number": 1133 - } - ], "src/agents/pi-embedded-runner.e2e.test.ts": [ { "type": "Secret Keyword", @@ -12309,22 +11554,13 @@ "line_number": 122 } ], - "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 159 - } - ], "src/agents/pi-embedded-runner/model.ts": [ { "type": "Secret Keyword", "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": [ @@ -12336,52 +11572,13 @@ "line_number": 114 } ], - "src/agents/pi-extensions/compaction-safeguard.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/agents/pi-extensions/compaction-safeguard.test.ts", - "hashed_secret": "0091061a3babbe6f11d48aa0142e22341b3ea446", - "is_verified": false, - "line_number": 665 - }, - { - "type": "Hex High Entropy String", - "filename": "src/agents/pi-extensions/compaction-safeguard.test.ts", - "hashed_secret": "ef678205593788329ff416ce5c65fa04f33a05bd", - "is_verified": false, - "line_number": 811 - }, + "src/agents/pi-tools.safe-bins.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/pi-extensions/compaction-safeguard.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", + "filename": "src/agents/pi-tools.safe-bins.e2e.test.ts", + "hashed_secret": "3ea88a727641fd5571b5e126ce87032377be1e7f", "is_verified": false, - "line_number": 1490 - } - ], - "src/agents/sandbox/browser.novnc-url.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/sandbox/browser.novnc-url.test.ts", - "hashed_secret": "16c002d49d19805aa1bfba58e9c90b5476054b07", - "is_verified": false, - "line_number": 18 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/sandbox/browser.novnc-url.test.ts", - "hashed_secret": "7ce0359f12857f2a90c7de465f40a95f01cb5da9", - "is_verified": false, - "line_number": 27 - } - ], - "src/agents/sandbox/sanitize-env-vars.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/sandbox/sanitize-env-vars.test.ts", - "hashed_secret": "c747c6b0a7bb9c6337b81875af1a9f9568c740ad", - "is_verified": false, - "line_number": 8 + "line_number": 126 } ], "src/agents/sanitize-for-prompt.test.ts": [ @@ -12393,141 +11590,65 @@ "line_number": 28 } ], - "src/agents/session-transcript-repair.attachments.test.ts": [ + "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/session-transcript-repair.attachments.test.ts", - "hashed_secret": "d25df4833026f016b73dcfa20f33bf753daf7593", - "is_verified": false, - "line_number": 32 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/session-transcript-repair.attachments.test.ts", - "hashed_secret": "30b1e9e71b6de9c2d579657e551b95f7eaae406d", - "is_verified": false, - "line_number": 47 - } - ], - "src/agents/skills-install.download.test.ts": [ - { - "type": "Base64 High Entropy String", - "filename": "src/agents/skills-install.download.test.ts", - "hashed_secret": "459acf71d00174faf13cfeee88513702c82d3cb3", - "is_verified": false, - "line_number": 51 - } - ], - "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts", + "filename": "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts", "hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb", "is_verified": false, - "line_number": 118 + "line_number": 103 } ], - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts": [ + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", + "filename": "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 181 + "line_number": 147 } ], - "src/agents/skills.test.ts": [ + "src/agents/skills.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/skills.test.ts", + "filename": "src/agents/skills.e2e.test.ts", "hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d", "is_verified": false, - "line_number": 255 + "line_number": 250 }, { "type": "Secret Keyword", - "filename": "src/agents/skills.test.ts", + "filename": "src/agents/skills.e2e.test.ts", "hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448", "is_verified": false, - "line_number": 313 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/skills.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 352 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/skills.test.ts", - "hashed_secret": "895900e6b5d30fa84fbff6e4e4c10eb5a63c5f8f", - "is_verified": false, - "line_number": 427 + "line_number": 277 } ], - "src/agents/system-prompt.test.ts": [ + "src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/system-prompt.test.ts", - "hashed_secret": "0a111adae31992afa2873148fdfcaf39e70ec7d8", + "filename": "src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts", + "hashed_secret": "9da08ab1e27fe0ae2ba6101aea30edcec02d21a4", "is_verified": false, - "line_number": 76 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/system-prompt.test.ts", - "hashed_secret": "2b3140fdd098f7cb2af72632ac2c0df772b8e90a", - "is_verified": false, - "line_number": 83 + "line_number": 45 } ], - "src/agents/tools/pdf-tool.test.ts": [ + "src/agents/tools/web-fetch.ssrf.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/tools/pdf-tool.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 74 - } - ], - "src/agents/tools/web-fetch.ssrf.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-fetch.ssrf.test.ts", + "filename": "src/agents/tools/web-fetch.ssrf.e2e.test.ts", "hashed_secret": "5ce8e9d54c77266fff990194d2219a708c59b76c", "is_verified": false, - "line_number": 84 + "line_number": 73 } ], - "src/agents/tools/web-search.test.ts": [ + "src/agents/tools/web-search.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.test.ts", + "filename": "src/agents/tools/web-search.e2e.test.ts", "hashed_secret": "c8d313eac6d38274ccfc0fa7935c68bd61d5bc2f", "is_verified": false, - "line_number": 105 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.test.ts", - "hashed_secret": "1561970702b4bf5bb10266b292e545ec14fc602e", - "is_verified": false, - "line_number": 224 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.test.ts", - "hashed_secret": "c930e4d402a279c3feea98578f716d5665c8cc5d", - "is_verified": false, - "line_number": 228 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-search.test.ts", - "hashed_secret": "5c1a5088b7790a73e236f21d65a5e4384a742af0", - "is_verified": false, - "line_number": 231 + "line_number": 129 } ], "src/agents/tools/web-search.ts": [ @@ -12539,82 +11660,61 @@ "line_number": 254 } ], - "src/agents/tools/web-tools.enabled-defaults.test.ts": [ + "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", - "hashed_secret": "f6558c30641dd2d38c6e8e7389dd724327c9627e", + "filename": "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts", + "hashed_secret": "47b249a75ca78fdb578d0f28c33685e27ea82684", "is_verified": false, - "line_number": 53 + "line_number": 181 }, { "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", - "hashed_secret": "59fa0cc80b21eb4ea49590dc887b95f5ae7e0bf5", + "filename": "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts", + "hashed_secret": "d0ffd81d6d7ad1bc3c365660fe8882480c9a986e", "is_verified": false, - "line_number": 55 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", - "hashed_secret": "354a920b3d519d11b737695308dab1bfcf77dbb3", - "is_verified": false, - "line_number": 57 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", - "hashed_secret": "7ec282d2630c12bf9241ef44db50f1f780cdaa79", - "is_verified": false, - "line_number": 59 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", - "hashed_secret": "8ba65d9239fd59ffc16e202cb480d15e35bce964", - "is_verified": false, - "line_number": 60 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", - "hashed_secret": "fb724421f6f4a53c0a73101ea88e4090cabb7b1a", - "is_verified": false, - "line_number": 461 + "line_number": 187 } ], - "src/agents/tools/web-tools.fetch.test.ts": [ + "src/agents/tools/web-tools.fetch.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.fetch.test.ts", + "filename": "src/agents/tools/web-tools.fetch.e2e.test.ts", "hashed_secret": "5ce8e9d54c77266fff990194d2219a708c59b76c", "is_verified": false, - "line_number": 133 + "line_number": 246 } ], - "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts": [ + "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts", + "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 60 + "line_number": 56 }, { "type": "Secret Keyword", - "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts", + "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 142 + "line_number": 62 } ], - "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts": [ + "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts": [ { - "type": "Hex High Entropy String", - "filename": "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts", - "hashed_secret": "ff998abc1ce6d8f01a675fa197368e44c8916e9c", + "type": "Secret Keyword", + "filename": "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts", + "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 216 + "line_number": 42 + }, + { + "type": "Secret Keyword", + "filename": "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts", + "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", + "is_verified": false, + "line_number": 149 } ], "src/auto-reply/status.test.ts": [ @@ -12633,13 +11733,6 @@ "hashed_secret": "6af3c121ed4a752936c297cddfb7b00394eabf10", "is_verified": false, "line_number": 72 - }, - { - "type": "Secret Keyword", - "filename": "src/browser/bridge-server.auth.test.ts", - "hashed_secret": "26aaf463d1d85670b71c6a84a2f644ad5995efc8", - "is_verified": false, - "line_number": 93 } ], "src/browser/browser-utils.test.ts": [ @@ -12667,22 +11760,6 @@ "line_number": 243 } ], - "src/channels/account-snapshot-fields.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/channels/account-snapshot-fields.test.ts", - "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", - "is_verified": false, - "line_number": 10 - }, - { - "type": "Secret Keyword", - "filename": "src/channels/account-snapshot-fields.test.ts", - "hashed_secret": "071d3673192b4b44a84aa73ac9d00c155821303b", - "is_verified": false, - "line_number": 11 - } - ], "src/channels/plugins/plugins-channel.test.ts": [ { "type": "Hex High Entropy String", @@ -12692,95 +11769,13 @@ "line_number": 64 } ], - "src/cli/acp-cli.option-collisions.test.ts": [ + "src/cli/program.smoke.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/cli/acp-cli.option-collisions.test.ts", - "hashed_secret": "e5d0d3f3697f96d69545f36ab2eaf1f9d4e2a8f8", + "filename": "src/cli/program.smoke.e2e.test.ts", + "hashed_secret": "8689a958b58e4a6f7da6211e666da8e17651697c", "is_verified": false, - "line_number": 94 - }, - { - "type": "Secret Keyword", - "filename": "src/cli/acp-cli.option-collisions.test.ts", - "hashed_secret": "8eac0f7ffe62469bf88ebdb208115f1ce3567d07", - "is_verified": false, - "line_number": 106 - } - ], - "src/cli/command-secret-gateway.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/command-secret-gateway.test.ts", - "hashed_secret": "68c46e84d76d2e7e686e5158bf598909abd4e45b", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Secret Keyword", - "filename": "src/cli/command-secret-gateway.test.ts", - "hashed_secret": "3a20a67d6535d75cf0852a72a37e9c5a8fdb9976", - "is_verified": false, - "line_number": 120 - } - ], - "src/cli/config-cli.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/config-cli.test.ts", - "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", - "is_verified": false, - "line_number": 200 - } - ], - "src/cli/daemon-cli/register-service-commands.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/daemon-cli/register-service-commands.test.ts", - "hashed_secret": "d717176567cedb0012b6b5f4653f688bbb9ccb8b", - "is_verified": false, - "line_number": 67 - } - ], - "src/cli/daemon-cli/status.gather.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/daemon-cli/status.gather.test.ts", - "hashed_secret": "c09520299bf32111c9f2ebafaf5a9981ec51a91d", - "is_verified": false, - "line_number": 208 - } - ], - "src/cli/program/register.onboard.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/program/register.onboard.test.ts", - "hashed_secret": "5da1c2e689ee66cf379bc74d3eafd0460db70ca0", - "is_verified": false, - "line_number": 126 - } - ], - "src/cli/qr-cli.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/cli/qr-cli.test.ts", - "hashed_secret": "8fc5be300f480d027174b514b563e77548b636f2", - "is_verified": false, - "line_number": 230 - }, - { - "type": "Secret Keyword", - "filename": "src/cli/qr-cli.test.ts", - "hashed_secret": "f1355ae408e2068355dad8f3a503c2eaedefc0c6", - "is_verified": false, - "line_number": 248 - }, - { - "type": "Secret Keyword", - "filename": "src/cli/qr-cli.test.ts", - "hashed_secret": "4316c1b21634c0e3f4d53bfb3ca2f48dde69bc4e", - "is_verified": false, - "line_number": 285 + "line_number": 215 } ], "src/cli/update-cli.test.ts": [ @@ -12792,61 +11787,48 @@ "line_number": 277 } ], - "src/commands/auth-choice.apply-helpers.test.ts": [ + "src/commands/auth-choice.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply-helpers.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "2480500ff391183070fe22ba8665a8be19350833", "is_verified": false, - "line_number": 105 + "line_number": 454 }, { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply-helpers.test.ts", - "hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "844ae5308654406d80db6f2b3d0beb07d616f9e1", "is_verified": false, - "line_number": 111 + "line_number": 487 }, { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply-helpers.test.ts", - "hashed_secret": "d23a3625f8598b9cd747e74c1f1676f5ba7be530", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", "is_verified": false, - "line_number": 330 - } - ], - "src/commands/auth-choice.apply.minimax.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply.minimax.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", - "is_verified": false, - "line_number": 162 + "line_number": 549 }, { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply.minimax.test.ts", - "hashed_secret": "c090713b544ae4cabb48f2153079955947c6e013", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "266e955b27b5fc2c2f532e446f2e71c3667a4cd9", "is_verified": false, - "line_number": 175 - } - ], - "src/commands/auth-choice.apply.openai.test.ts": [ + "line_number": 584 + }, { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply.openai.test.ts", - "hashed_secret": "c5831e54ef6edcf968300daf4a9a84580bc2ed37", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "1b4d8423b11d32dd0c466428ac81de84a4a9442b", "is_verified": false, - "line_number": 31 - } - ], - "src/commands/auth-choice.apply.volcengine-byteplus.test.ts": [ + "line_number": 726 + }, { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.apply.volcengine-byteplus.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "c24e00b94c972ed497d5961212ac96f0dffb4f7a", "is_verified": false, - "line_number": 55 + "line_number": 798 } ], "src/commands/auth-choice.preferred-provider.ts": [ @@ -12858,107 +11840,31 @@ "line_number": 8 } ], - "src/commands/auth-choice.test.ts": [ + "src/commands/configure.gateway-auth.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", - "is_verified": false, - "line_number": 679 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "c5831e54ef6edcf968300daf4a9a84580bc2ed37", - "is_verified": false, - "line_number": 745 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "844ae5308654406d80db6f2b3d0beb07d616f9e1", - "is_verified": false, - "line_number": 955 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "1c62e8a666fb3e1b8c9b0c1cab8e1d6bbb136580", - "is_verified": false, - "line_number": 1065 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "1b4d8423b11d32dd0c466428ac81de84a4a9442b", - "is_verified": false, - "line_number": 1222 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "c24e00b94c972ed497d5961212ac96f0dffb4f7a", - "is_verified": false, - "line_number": 1234 - } - ], - "src/commands/channels.config-only-status-output.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/channels.config-only-status-output.test.ts", - "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", - "is_verified": false, - "line_number": 149 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/channels.config-only-status-output.test.ts", - "hashed_secret": "071d3673192b4b44a84aa73ac9d00c155821303b", - "is_verified": false, - "line_number": 150 - } - ], - "src/commands/configure.gateway-auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/configure.gateway-auth.test.ts", + "filename": "src/commands/configure.gateway-auth.e2e.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 24 + "line_number": 21 }, { "type": "Secret Keyword", - "filename": "src/commands/configure.gateway-auth.test.ts", + "filename": "src/commands/configure.gateway-auth.e2e.test.ts", "hashed_secret": "d5d4cd07616a542891b7ec2d0257b3a24b69856e", "is_verified": false, - "line_number": 65 + "line_number": 62 } ], - "src/commands/daemon-install-helpers.test.ts": [ + "src/commands/daemon-install-helpers.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/daemon-install-helpers.test.ts", + "filename": "src/commands/daemon-install-helpers.e2e.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, "line_number": 128 } ], - "src/commands/doctor-gateway-auth-token.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/doctor-gateway-auth-token.test.ts", - "hashed_secret": "f1355ae408e2068355dad8f3a503c2eaedefc0c6", - "is_verified": false, - "line_number": 166 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/doctor-gateway-auth-token.test.ts", - "hashed_secret": "0b75f28abf6b39a10d1398ce5a95e93a5cebbbda", - "is_verified": false, - "line_number": 206 - } - ], "src/commands/doctor-memory-search.test.ts": [ { "type": "Secret Keyword", @@ -12966,74 +11872,52 @@ "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", "is_verified": false, "line_number": 43 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/doctor-memory-search.test.ts", - "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", - "is_verified": false, - "line_number": 278 } ], - "src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts": [ + "src/commands/model-picker.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts", - "hashed_secret": "f3c7399f056377fc3dae16a9854fe636b720d3d0", - "is_verified": false, - "line_number": 98 - } - ], - "src/commands/gateway-install-token.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/gateway-install-token.test.ts", - "hashed_secret": "f3c7399f056377fc3dae16a9854fe636b720d3d0", - "is_verified": false, - "line_number": 143 - } - ], - "src/commands/gateway-status/helpers.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/gateway-status/helpers.test.ts", - "hashed_secret": "1e1ff291f3b48b7e5b54828396f264ba43379076", - "is_verified": false, - "line_number": 183 - } - ], - "src/commands/message.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/message.test.ts", - "hashed_secret": "3bb1ec510d35ab2af7d05d8bbd5f0820333f1a0d", - "is_verified": false, - "line_number": 194 - } - ], - "src/commands/model-picker.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/model-picker.test.ts", + "filename": "src/commands/model-picker.e2e.test.ts", "hashed_secret": "5b924ca5330ede58702a5b0e414207b90fb1aef3", "is_verified": false, - "line_number": 105 + "line_number": 127 } ], - "src/commands/onboard-auth.config-core.kilocode.test.ts": [ + "src/commands/models/list.status.e2e.test.ts": [ { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.config-core.kilocode.test.ts", - "hashed_secret": "01800a0712a2a1aa928b95c4745e9ee06673925b", + "type": "Base64 High Entropy String", + "filename": "src/commands/models/list.status.e2e.test.ts", + "hashed_secret": "d6ae2508a78a232d5378ef24b85ce40cbb4d7ff0", "is_verified": false, - "line_number": 163 + "line_number": 12 + }, + { + "type": "Base64 High Entropy String", + "filename": "src/commands/models/list.status.e2e.test.ts", + "hashed_secret": "2d8012102440ea97852b3152239218f00579bafa", + "is_verified": false, + "line_number": 19 + }, + { + "type": "Base64 High Entropy String", + "filename": "src/commands/models/list.status.e2e.test.ts", + "hashed_secret": "51848e2be4b461a549218d3167f19c01be6b98b8", + "is_verified": false, + "line_number": 51 }, { "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.config-core.kilocode.test.ts", - "hashed_secret": "8d2ce71c6723bf46f6c166984b4ddb597f92322a", + "filename": "src/commands/models/list.status.e2e.test.ts", + "hashed_secret": "51848e2be4b461a549218d3167f19c01be6b98b8", "is_verified": false, - "line_number": 190 + "line_number": 51 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/models/list.status.e2e.test.ts", + "hashed_secret": "1c1e381bfb72d3b7bfca9437053d9875356680f0", + "is_verified": false, + "line_number": 57 } ], "src/commands/onboard-auth.config-minimax.ts": [ @@ -13052,43 +11936,94 @@ "line_number": 79 } ], - "src/commands/onboard-auth.credentials.test.ts": [ + "src/commands/onboard-auth.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.credentials.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", - "is_verified": false, - "line_number": 97 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.credentials.test.ts", - "hashed_secret": "3fabe94b84be76552a40fab6d3284697b136ea23", - "is_verified": false, - "line_number": 139 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.credentials.test.ts", - "hashed_secret": "aec738f7a0d1056bee31567d522e7191a13ce31a", - "is_verified": false, - "line_number": 190 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.credentials.test.ts", - "hashed_secret": "9705dbfd5f922106b199746632af2b66b02c3f0a", - "is_verified": false, - "line_number": 191 - } - ], - "src/commands/onboard-auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.test.ts", + "filename": "src/commands/onboard-auth.e2e.test.ts", "hashed_secret": "e184b402822abc549b37689c84e8e0e33c39a1f1", "is_verified": false, - "line_number": 423 + "line_number": 272 + } + ], + "src/commands/onboard-custom.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-custom.e2e.test.ts", + "hashed_secret": "62e6748c6bb4c4a0f785a28cdd7d41ef212c0091", + "is_verified": false, + "line_number": 238 + } + ], + "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", + "is_verified": false, + "line_number": 153 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "07a6b9cec637c806195e8aa7e5c0851ab03dc35e", + "is_verified": false, + "line_number": 191 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", + "is_verified": false, + "line_number": 234 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "65547299f940eca3dc839f3eac85e8a78a6deb05", + "is_verified": false, + "line_number": 282 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "2833d098c110602e4c8d577fbfdb423a9ffd58e9", + "is_verified": false, + "line_number": 304 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "266e955b27b5fc2c2f532e446f2e71c3667a4cd9", + "is_verified": false, + "line_number": 338 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "995b80728ee01edb90ddfed07870bbab405df19f", + "is_verified": false, + "line_number": 366 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "b65888424ecafcc98bfd803b24817e4dadf821f8", + "is_verified": false, + "line_number": 383 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "62e6748c6bb4c4a0f785a28cdd7d41ef212c0091", + "is_verified": false, + "line_number": 402 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "8818d3b7c102fd6775af9e1390e5ed3a128473fb", + "is_verified": false, + "line_number": 447 } ], "src/commands/onboard-non-interactive/api-keys.ts": [ @@ -13118,13 +12053,13 @@ "line_number": 60 } ], - "src/commands/zai-endpoint-detect.test.ts": [ + "src/commands/zai-endpoint-detect.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/zai-endpoint-detect.test.ts", + "filename": "src/commands/zai-endpoint-detect.e2e.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 61 + "line_number": 24 } ], "src/config/config-misc.test.ts": [ @@ -13177,57 +12112,6 @@ "line_number": 33 } ], - "src/config/config.web-search-provider.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "a704b0feaf024ae73cda6859104dd323bc36b451", - "is_verified": false, - "line_number": 78 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "6984b2d1edb45c9ba5de8d29e9cd9a2613c6a170", - "is_verified": false, - "line_number": 83 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "bfe8fe037d4fe1aa6c0aeecf94efe2ebc265c6f8", - "is_verified": false, - "line_number": 88 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "4ee210c6480582752ad7f74c74bd63a3d4531e51", - "is_verified": false, - "line_number": 93 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "6d166fccc1c1a5193f7f7397705c84a184d68c0e", - "is_verified": false, - "line_number": 98 - }, - { - "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "0f7f0fad47a1470a44be65dac2b848a99e28302c", - "is_verified": false, - "line_number": 108 - } - ], "src/config/env-preserve-io.test.ts": [ { "type": "Secret Keyword", @@ -13304,15 +12188,6 @@ "line_number": 282 } ], - "src/config/io.runtime-snapshot-write.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/io.runtime-snapshot-write.test.ts", - "hashed_secret": "c7106700045d8a274b6702325ecf9bcb60d42318", - "is_verified": false, - "line_number": 34 - } - ], "src/config/io.write-config.test.ts": [ { "type": "Secret Keyword", @@ -13329,13 +12204,6 @@ "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, "line_number": 13 - }, - { - "type": "Secret Keyword", - "filename": "src/config/model-alias-defaults.test.ts", - "hashed_secret": "fa9144b340ea7886885669e2e7a808c86ee14a07", - "is_verified": false, - "line_number": 114 } ], "src/config/redact-snapshot.test.ts": [ @@ -13381,13 +12249,6 @@ "is_verified": false, "line_number": 95 }, - { - "type": "Private Key", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_verified": false, - "line_number": 123 - }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", @@ -13395,20 +12256,6 @@ "is_verified": false, "line_number": 227 }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "939bb46a04c3640c8c427e92b1b557e882e2d2a0", - "is_verified": false, - "line_number": 262 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "7505d64a54e061b7acd54ccd58b49dc43500b635", - "is_verified": false, - "line_number": 302 - }, { "type": "Base64 High Entropy String", "filename": "src/config/redact-snapshot.test.ts", @@ -13437,34 +12284,6 @@ "is_verified": false, "line_number": 771 }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "22edfa62d61f01fead87e40562f8c8a51caa2806", - "is_verified": false, - "line_number": 783 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "33e65bb7ffff7e05b434318409b212f8724bc961", - "is_verified": false, - "line_number": 806 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "dc2e131fd7ef4cf84345ad7f6c92c3d656051ede", - "is_verified": false, - "line_number": 831 - }, - { - "type": "Secret Keyword", - "filename": "src/config/redact-snapshot.test.ts", - "hashed_secret": "0834708d0ed84f1d023353afc867fb0a4e5ebfea", - "is_verified": false, - "line_number": 838 - }, { "type": "Secret Keyword", "filename": "src/config/redact-snapshot.test.ts", @@ -13558,31 +12377,6 @@ "line_number": 10 } ], - "src/config/talk.normalize.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/talk.normalize.test.ts", - "hashed_secret": "dff6d4ff5dc357cf451d1855ab9cbda562645c9f", - "is_verified": false, - "line_number": 30 - }, - { - "type": "Secret Keyword", - "filename": "src/config/talk.normalize.test.ts", - "hashed_secret": "653d2545f6d16efa76ad7740bab466e175c4efd3", - "is_verified": false, - "line_number": 101 - } - ], - "src/config/telegram-webhook-port.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/config/telegram-webhook-port.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 10 - } - ], "src/config/telegram-webhook-secret.test.ts": [ { "type": "Secret Keyword", @@ -13592,20 +12386,13 @@ "line_number": 10 } ], - "src/docker-setup.e2e.test.ts": [ + "src/docker-setup.test.ts": [ { "type": "Base64 High Entropy String", - "filename": "src/docker-setup.e2e.test.ts", + "filename": "src/docker-setup.test.ts", "hashed_secret": "32ac33b537769e97787f70ef85576cc243fab934", "is_verified": false, - "line_number": 178 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/docker-setup.e2e.test.ts", - "hashed_secret": "299e5b3d10d301eb479c0b84b16d750cb799e274", - "is_verified": false, - "line_number": 250 + "line_number": 131 } ], "src/gateway/auth-rate-limit.ts": [ @@ -13632,13 +12419,6 @@ "is_verified": false, "line_number": 112 }, - { - "type": "Secret Keyword", - "filename": "src/gateway/auth.test.ts", - "hashed_secret": "052f076c732648ab32d2fcde9fe255319bfa0c7b", - "is_verified": false, - "line_number": 128 - }, { "type": "Secret Keyword", "filename": "src/gateway/auth.test.ts", @@ -13676,20 +12456,6 @@ "is_verified": false, "line_number": 611 }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", - "is_verified": false, - "line_number": 638 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc", - "is_verified": false, - "line_number": 646 - }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", @@ -13710,154 +12476,15 @@ "hashed_secret": "bddc29032de580fb53b3a9a0357dd409086db800", "is_verified": false, "line_number": 704 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "2e7d14ce1d0b584f112cca09f638557e42a2617b", - "is_verified": false, - "line_number": 724 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "802c9dbd2953f682a244abc0ec00ad564ac0eb7d", - "is_verified": false, - "line_number": 869 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/call.test.ts", - "hashed_secret": "1e1ff291f3b48b7e5b54828396f264ba43379076", - "is_verified": false, - "line_number": 901 } ], - "src/gateway/client.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/client.test.ts", - "hashed_secret": "2c35baf5aa803a12df64c64b97df0445c46aeb03", - "is_verified": false, - "line_number": 126 - } - ], - "src/gateway/client.watchdog.test.ts": [ + "src/gateway/client.e2e.test.ts": [ { "type": "Private Key", - "filename": "src/gateway/client.watchdog.test.ts", + "filename": "src/gateway/client.e2e.test.ts", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 89 - } - ], - "src/gateway/credential-precedence.parity.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/credential-precedence.parity.test.ts", - "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", - "is_verified": false, - "line_number": 24 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credential-precedence.parity.test.ts", - "hashed_secret": "de1c41e8ece73f5d5c259bb37eccb59a542b91dc", - "is_verified": false, - "line_number": 34 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credential-precedence.parity.test.ts", - "hashed_secret": "052f076c732648ab32d2fcde9fe255319bfa0c7b", - "is_verified": false, - "line_number": 80 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credential-precedence.parity.test.ts", - "hashed_secret": "1e1ff291f3b48b7e5b54828396f264ba43379076", - "is_verified": false, - "line_number": 99 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credential-precedence.parity.test.ts", - "hashed_secret": "d51f846285cbc6d1dd76677a0fd588c8df44e506", - "is_verified": false, - "line_number": 132 - } - ], - "src/gateway/credentials.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "052f076c732648ab32d2fcde9fe255319bfa0c7b", - "is_verified": false, - "line_number": 15 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "1e1ff291f3b48b7e5b54828396f264ba43379076", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", - "is_verified": false, - "line_number": 19 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", - "is_verified": false, - "line_number": 81 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "de1c41e8ece73f5d5c259bb37eccb59a542b91dc", - "is_verified": false, - "line_number": 227 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "e951da0670d747fb42c25e584913ced2a22df456", - "is_verified": false, - "line_number": 258 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "c4268595e9bc82fd8385d7f5c31cff96d677e31d", - "is_verified": false, - "line_number": 269 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "bc5f9ea9a906cf0641cf9e227b6b9ae3cdc9df59", - "is_verified": false, - "line_number": 285 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "d51f846285cbc6d1dd76677a0fd588c8df44e506", - "is_verified": false, - "line_number": 455 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/credentials.test.ts", - "hashed_secret": "60acdb59369429ffd0729487ec638eb0f7f12976", - "is_verified": false, - "line_number": 474 + "line_number": 85 } ], "src/gateway/gateway-cli-backend.live.test.ts": [ @@ -13878,15 +12505,6 @@ "line_number": 384 } ], - "src/gateway/server-methods/push.test.ts": [ - { - "type": "Private Key", - "filename": "src/gateway/server-methods/push.test.ts", - "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_verified": false, - "line_number": 81 - } - ], "src/gateway/server-methods/skills.update.normalizes-api-key.test.ts": [ { "type": "Secret Keyword", @@ -13905,31 +12523,38 @@ "line_number": 14 } ], - "src/gateway/server.auth.control-ui.suite.ts": [ + "src/gateway/server.auth.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/gateway/server.auth.control-ui.suite.ts", + "filename": "src/gateway/server.auth.e2e.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 239 - } - ], - "src/gateway/server.skills-status.test.ts": [ + "line_number": 460 + }, { "type": "Secret Keyword", - "filename": "src/gateway/server.skills-status.test.ts", + "filename": "src/gateway/server.auth.e2e.test.ts", + "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", + "is_verified": false, + "line_number": 478 + } + ], + "src/gateway/server.skills-status.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/server.skills-status.e2e.test.ts", "hashed_secret": "1cc6bff0f84efb2d3ff4fa1347f3b2bc173aaff0", "is_verified": false, - "line_number": 14 + "line_number": 13 } ], - "src/gateway/server.talk-config.test.ts": [ + "src/gateway/server.talk-config.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/gateway/server.talk-config.test.ts", + "filename": "src/gateway/server.talk-config.e2e.test.ts", "hashed_secret": "3c310634864babb081f0b617c14bc34823d7e369", "is_verified": false, - "line_number": 70 + "line_number": 13 } ], "src/gateway/session-utils.test.ts": [ @@ -13941,36 +12566,6 @@ "line_number": 563 } ], - "src/gateway/startup-auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/gateway/startup-auth.test.ts", - "hashed_secret": "1951c80555441588e8707fa68a6084a91c8a114a", - "is_verified": false, - "line_number": 125 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/startup-auth.test.ts", - "hashed_secret": "0b75f28abf6b39a10d1398ce5a95e93a5cebbbda", - "is_verified": false, - "line_number": 255 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/startup-auth.test.ts", - "hashed_secret": "f1355ae408e2068355dad8f3a503c2eaedefc0c6", - "is_verified": false, - "line_number": 282 - }, - { - "type": "Secret Keyword", - "filename": "src/gateway/startup-auth.test.ts", - "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", - "is_verified": false, - "line_number": 448 - } - ], "src/gateway/test-openai-responses-model.ts": [ { "type": "Secret Keyword", @@ -14036,30 +12631,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 - } - ], - "src/infra/push-apns.test.ts": [ - { - "type": "Private Key", - "filename": "src/infra/push-apns.test.ts", - "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", - "is_verified": false, - "line_number": 80 + "line_number": 126 } ], "src/infra/shell-env.test.ts": [ @@ -14114,14 +12700,7 @@ "filename": "src/line/bot-handlers.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 107 - }, - { - "type": "Secret Keyword", - "filename": "src/line/bot-handlers.test.ts", - "hashed_secret": "d76baddf1b9e3d8e31216f22c73d65d2e91ada7b", - "is_verified": false, - "line_number": 358 + "line_number": 101 } ], "src/line/bot-message-context.test.ts": [ @@ -14131,13 +12710,6 @@ "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, "line_number": 18 - }, - { - "type": "Hex High Entropy String", - "filename": "src/line/bot-message-context.test.ts", - "hashed_secret": "d369d8c413645b43df8ac26be7295cd15a64f9bf", - "is_verified": false, - "line_number": 179 } ], "src/line/monitor.fail-closed.test.ts": [ @@ -14149,15 +12721,6 @@ "line_number": 22 } ], - "src/line/monitor.lifecycle.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/line/monitor.lifecycle.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 91 - } - ], "src/line/webhook-node.test.ts": [ { "type": "Secret Keyword", @@ -14206,22 +12769,13 @@ "line_number": 88 } ], - "src/media-understanding/apply.echo-transcript.test.ts": [ + "src/media-understanding/apply.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/media-understanding/apply.echo-transcript.test.ts", + "filename": "src/media-understanding/apply.e2e.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 15 - } - ], - "src/media-understanding/apply.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/apply.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 17 + "line_number": 12 } ], "src/media-understanding/providers/deepgram/audio.test.ts": [ @@ -14242,22 +12796,6 @@ "line_number": 56 } ], - "src/media-understanding/providers/mistral/index.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/providers/mistral/index.test.ts", - "hashed_secret": "5b29ef735a0cc9246f2024fe148fa051ddcd9c7b", - "is_verified": false, - "line_number": 23 - }, - { - "type": "Secret Keyword", - "filename": "src/media-understanding/providers/mistral/index.test.ts", - "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", - "is_verified": false, - "line_number": 38 - } - ], "src/media-understanding/providers/openai/audio.test.ts": [ { "type": "Secret Keyword", @@ -14285,31 +12823,6 @@ "line_number": 31 } ], - "src/media-understanding/runner.video.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/media-understanding/runner.video.test.ts", - "hashed_secret": "a47110e348a3063541fb1f1f640d635d457181a0", - "is_verified": false, - "line_number": 17 - }, - { - "type": "Secret Keyword", - "filename": "src/media-understanding/runner.video.test.ts", - "hashed_secret": "2568d97e538e07521431c9ea738e5c2df14df7a2", - "is_verified": false, - "line_number": 88 - } - ], - "src/memory/embeddings-ollama.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/memory/embeddings-ollama.test.ts", - "hashed_secret": "24ff85e3f39fdc772fc759b161935393b6df7071", - "is_verified": false, - "line_number": 47 - } - ], "src/memory/embeddings-voyage.test.ts": [ { "type": "Secret Keyword", @@ -14364,160 +12877,17 @@ "filename": "src/pairing/setup-code.test.ts", "hashed_secret": "4914c103484773b5a8e18448b11919bb349cbff8", "is_verified": false, - "line_number": 30 - }, - { - "type": "Secret Keyword", - "filename": "src/pairing/setup-code.test.ts", - "hashed_secret": "1951c80555441588e8707fa68a6084a91c8a114a", - "is_verified": false, - "line_number": 74 - }, - { - "type": "Secret Keyword", - "filename": "src/pairing/setup-code.test.ts", - "hashed_secret": "f1355ae408e2068355dad8f3a503c2eaedefc0c6", - "is_verified": false, - "line_number": 106 + "line_number": 31 }, { "type": "Secret Keyword", "filename": "src/pairing/setup-code.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 370 - } - ], - "src/secrets/apply.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/secrets/apply.test.ts", - "hashed_secret": "b933c37f090368dee5ab803d71af8f5551729a9a", - "is_verified": false, - "line_number": 75 - }, - { - "type": "Base64 High Entropy String", - "filename": "src/secrets/apply.test.ts", - "hashed_secret": "b99aa0d13685d4177199dcdb170d90032408b634", - "is_verified": false, - "line_number": 106 - }, - { - "type": "Secret Keyword", - "filename": "src/secrets/apply.test.ts", - "hashed_secret": "bb0a04dd3612988998c812bc3ad580ba0fb9d905", - "is_verified": false, - "line_number": 360 - }, - { - "type": "Secret Keyword", - "filename": "src/secrets/apply.test.ts", - "hashed_secret": "942c7142a36b069509b957db07321a1cb9b2123a", - "is_verified": false, - "line_number": 397 - }, - { - "type": "Secret Keyword", - "filename": "src/secrets/apply.test.ts", - "hashed_secret": "9c0faa509a7c3079f58421307ecbcaceb7cbd545", - "is_verified": false, - "line_number": 450 - }, - { - "type": "Secret Keyword", - "filename": "src/secrets/apply.test.ts", - "hashed_secret": "c9a4d024f4386d3a4b044de8cb52226383591481", - "is_verified": false, - "line_number": 483 - } - ], - "src/secrets/command-config.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/secrets/command-config.test.ts", - "hashed_secret": "e3801068cd8f45226d71fb7ccd94069d0fbba56d", - "is_verified": false, - "line_number": 14 - } - ], - "src/secrets/configure-plan.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/secrets/configure-plan.test.ts", - "hashed_secret": "68c46e84d76d2e7e686e5158bf598909abd4e45b", - "is_verified": false, - "line_number": 15 - }, - { - "type": "Secret Keyword", - "filename": "src/secrets/configure-plan.test.ts", - "hashed_secret": "b340b5722fdf4bae59f23b1b829bad0a50b98c2a", - "is_verified": false, - "line_number": 142 - } - ], - "src/secrets/path-utils.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/secrets/path-utils.test.ts", - "hashed_secret": "c00dbbc9dadfbe1e232e93a729dd4752fade0abf", - "is_verified": false, - "line_number": 54 - }, - { - "type": "Secret Keyword", - "filename": "src/secrets/path-utils.test.ts", - "hashed_secret": "ff3390557335ba88d37755e41514beb03bc499ec", - "is_verified": false, - "line_number": 72 - } - ], - "src/secrets/runtime.coverage.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/secrets/runtime.coverage.test.ts", - "hashed_secret": "e9a292f7f4d25b0d861458719c6115de3ec813c3", - "is_verified": false, - "line_number": 30 + "line_number": 357 } ], "src/security/audit.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "cf27add3cb4cb83efe9a48cf7289068fa869c4cd", - "is_verified": false, - "line_number": 1493 - }, - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", - "is_verified": false, - "line_number": 1969 - }, - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "071d3673192b4b44a84aa73ac9d00c155821303b", - "is_verified": false, - "line_number": 1970 - }, - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "7b231a50a498ef151e291795f46f56bee569eae5", - "is_verified": false, - "line_number": 1982 - }, - { - "type": "Secret Keyword", - "filename": "src/security/audit.test.ts", - "hashed_secret": "5a013c49508291c6816ac388f93a2c11973086ed", - "is_verified": false, - "line_number": 2058 - }, { "type": "Secret Keyword", "filename": "src/security/audit.test.ts", @@ -14533,40 +12903,6 @@ "line_number": 3486 } ], - "src/security/external-content.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/security/external-content.test.ts", - "hashed_secret": "e8e6c2284ab5bee4de2ee53880c8fc2a4728d3e8", - "is_verified": false, - "line_number": 148 - } - ], - "src/signal/identity.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/signal/identity.test.ts", - "hashed_secret": "99c962e8c62296bdc9a17f5caf91ce9bb4c7e0e6", - "is_verified": false, - "line_number": 15 - } - ], - "src/slack/monitor/monitor.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/slack/monitor/monitor.test.ts", - "hashed_secret": "431ef2b335d72ec03c3a5d6393c8ab87012bba48", - "is_verified": false, - "line_number": 68 - }, - { - "type": "Hex High Entropy String", - "filename": "src/slack/monitor/monitor.test.ts", - "hashed_secret": "6c8fd4b55b7a940cf3d484634cb4f2b9e1a8fe7a", - "is_verified": false, - "line_number": 78 - } - ], "src/telegram/monitor.test.ts": [ { "type": "Secret Keyword", @@ -14635,7 +12971,7 @@ "filename": "src/tui/gateway-chat.test.ts", "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", "is_verified": false, - "line_number": 60 + "line_number": 121 } ], "src/web/login.test.ts": [ @@ -14647,47 +12983,6 @@ "line_number": 60 } ], - "src/wizard/onboarding.gateway-config.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/wizard/onboarding.gateway-config.test.ts", - "hashed_secret": "358fffeb5cef5e34ae867e1d9edf2ba420ca2bf6", - "is_verified": false, - "line_number": 148 - }, - { - "type": "Secret Keyword", - "filename": "src/wizard/onboarding.gateway-config.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", - "is_verified": false, - "line_number": 162 - } - ], - "src/wizard/onboarding.secret-input.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/wizard/onboarding.secret-input.test.ts", - "hashed_secret": "358fffeb5cef5e34ae867e1d9edf2ba420ca2bf6", - "is_verified": false, - "line_number": 22 - } - ], - "src/wizard/onboarding.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/wizard/onboarding.test.ts", - "hashed_secret": "9c8c592cc7a339f158262ebc87ee5a0cce39ce83", - "is_verified": false, - "line_number": 403 - }, - { - "type": "Secret Keyword", - "filename": "src/wizard/onboarding.test.ts", - "hashed_secret": "69449f994d55805535b9e8fab16f6c39934e9ba4", - "is_verified": false, - "line_number": 487 - } - ], "ui/src/i18n/locales/en.ts": [ { "type": "Secret Keyword", @@ -14706,15 +13001,6 @@ "line_number": 61 } ], - "ui/src/ui/config-form.browser.test.ts": [ - { - "type": "Secret Keyword", - "filename": "ui/src/ui/config-form.browser.test.ts", - "hashed_secret": "c00dbbc9dadfbe1e232e93a729dd4752fade0abf", - "is_verified": false, - "line_number": 368 - } - ], "vendor/a2ui/README.md": [ { "type": "Secret Keyword", @@ -14725,5 +13011,5 @@ } ] }, - "generated_at": "2026-03-07T11:12:54Z" + "generated_at": "2026-03-07T18:17:47Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c83c7cc4346..e27dd13fd8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/local setup: default unset local `tools.profile` to `coding` instead of `messaging`, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek. - Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464) - Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web. - Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i. @@ -109,6 +110,7 @@ Docs: https://docs.openclaw.ai - TUI/session-key canonicalization: normalize `openclaw tui --session` values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc. - iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth. - Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant. +- Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant. - Sessions/subagent attachments: remove `attachments[].content.maxLength` from `sessions_spawn` schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera. - Runtime/tool-state stability: recover from dangling Anthropic `tool_use` after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr. - ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob. @@ -240,6 +242,10 @@ Docs: https://docs.openclaw.ai - iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman. - CLI/Docs memory help accuracy: clarify `openclaw memory status --deep` behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974. - Config/invalid-load fail-closed: stop converting `INVALID_CONFIG` into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc. +- Auto-reply/allowlist store account scoping: keep `/allowlist ... --store` writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix. +- Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (`x-forwarded-for` / `x-real-ip`) and rejecting `sec-fetch-site: cross-site`; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts. +- CLI/bootstrap Node version hint maintenance: replace hardcoded nvm `22` instructions in `openclaw.mjs` with `MIN_NODE_MAJOR` interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash. +- Discord/native slash command auth: honor `commands.allowFrom.discord` (and `commands.allowFrom["*"]`) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow. ## 2026.3.2 @@ -597,7 +603,10 @@ Docs: https://docs.openclaw.ai ### Fixes +- Dashboard/macOS auth handling: switch the macOS “Open Dashboard” flow from query-string token injection to URL fragments, stop persisting Control UI gateway tokens in browser localStorage, and scrub legacy stored tokens on load. Thanks @JNX03 for reporting. - Models/provider config precedence: prefer exact `models.providers.` matches before normalized provider aliases in embedded model resolution, preventing alias/canonical key collisions from applying the wrong provider `api`, `baseUrl`, or headers. (#35934) thanks @RealKai42. +- Hooks/auth throttling: reject non-`POST` `/hooks/*` requests before auth-failure accounting so unsupported methods can no longer burn the hook auth lockout budget and block legitimate webhook delivery. Thanks @JNX03 for reporting. +- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting. - Logging/Subsystem console timestamps: route subsystem console timestamp rendering through `formatConsoleTimestamp(...)` so `pretty` and timestamp-prefix output use local timezone formatting consistently instead of inline UTC `toISOString()` paths. (#25970) Thanks @openperf. - Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff. - Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42. diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt index 6c1ed9fb8b3..e0bad8e1fd1 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/AppUpdateHandlerTest.kt @@ -55,7 +55,7 @@ class AppUpdateHandlerTest { try { tmp.writeText("hello", Charsets.UTF_8) assertEquals( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret sha256Hex(tmp), ) } finally { diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 942939bb591..33e6bfa8adb 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -38,7 +38,9 @@ def maybe_decode_hex_keychain_secret(value) # `security find-generic-password -w` can return hex when the stored secret # includes newlines/non-printable bytes (like PEM files). - if decoded.include?("BEGIN PRIVATE KEY") || decoded.include?("END PRIVATE KEY") + beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret + endPemMarker = %w[END PRIVATE KEY].join(" ") + if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker) UI.message("Decoded hex-encoded ASC key content from Keychain.") return decoded end diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 141b7c43685..7105f60cb80 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -661,18 +661,20 @@ extension GatewayEndpointStore { components.path = "/" } - var queryItems: [URLQueryItem] = [] + var fragmentItems: [URLQueryItem] = [] if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - queryItems.append(URLQueryItem(name: "token", value: token)) + fragmentItems.append(URLQueryItem(name: "token", value: token)) } - if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines), - !password.isEmpty - { - queryItems.append(URLQueryItem(name: "password", value: password)) + components.queryItems = nil + if fragmentItems.isEmpty { + components.fragment = nil + } else { + var fragment = URLComponents() + fragment.queryItems = fragmentItems + components.fragment = fragment.percentEncodedQuery } - components.queryItems = queryItems.isEmpty ? nil : queryItems guard let url = components.url else { throw NSError(domain: "Dashboard", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Failed to build dashboard URL", diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 3d7796879f6..7aff5f3970b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -216,6 +216,20 @@ import Testing #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") } + @Test func dashboardURLUsesFragmentTokenAndOmitsPassword() throws { + let config: GatewayConnection.Config = try ( + url: #require(URL(string: "ws://127.0.0.1:18789")), + token: "abc123", + password: "sekret") + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .local, + localBasePath: "/control") + #expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123") + #expect(url.query == nil) + } + @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") #expect(url?.port == 18789) diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 0712a16661b..93c8d04b41a 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -22,3 +22,7 @@ openclaw agent --agent ops --message "Summarize logs" openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports" ``` + +## Notes + +- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names or `secretref-managed`), not resolved secret plaintext. diff --git a/docs/cli/models.md b/docs/cli/models.md index 700b562c353..e023784cc5e 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -38,6 +38,7 @@ Notes: - `models set ` accepts `provider/model` or an alias. - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - If you omit the provider, OpenClaw treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). +- `models status` may show `marker()` in auth output for non-secret placeholders (for example `OPENAI_API_KEY`, `secretref-managed`, `minimax-oauth`, `qwen-oauth`, `ollama-local`) instead of masking them as secrets. ### `models status` diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index db5e9476c55..f90a5de8ec0 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -14,7 +14,7 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot Command roles: - `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes). -- `audit`: read-only scan of configuration/auth stores and legacy residues for plaintext, unresolved refs, and precedence drift. +- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift. - `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required). - `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues. @@ -62,8 +62,13 @@ Scan OpenClaw state for: - plaintext secret storage - unresolved refs - precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs) +- generated `agents/*/agent/models.json` residues (provider `apiKey` values and sensitive provider headers) - legacy residues (legacy auth store entries, OAuth reminders) +Header residue note: + +- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). + ```bash openclaw secrets audit openclaw secrets audit --check diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 981bd95086c..2ad809d9599 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -212,6 +212,10 @@ is merged by default unless `models.mode` is set to `replace`. Merge mode precedence for matching provider IDs: -- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win. +- Non-empty `baseUrl` already present in the agent `models.json` wins. +- Non-empty `apiKey` in the agent `models.json` wins only when that provider is not SecretRef-managed in current config/auth-profile context. +- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`. - Other provider fields are refreshed from config and normalized catalog data. + +This marker-based persistence applies whenever OpenClaw regenerates `models.json`, including command-driven paths like `openclaw agent`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 749b0d2b261..249c35b7309 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1676,7 +1676,7 @@ Defaults for Talk mode (macOS/iOS/Android). `tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`: -Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved). +Local onboarding defaults new local configs to `tools.profile: "coding"` when unset (existing explicit profiles are preserved). | Profile | Includes | | ----------- | ----------------------------------------------------------------------------------------- | @@ -2004,7 +2004,9 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model - Use `authHeader: true` + `headers` for custom auth needs. - Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`). - Merge precedence for matching provider IDs: - - Non-empty agent `models.json` `apiKey`/`baseUrl` win. + - Non-empty agent `models.json` `baseUrl` values win. + - Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context. + - SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config. - Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values. - Use `models.mode: "replace"` when you want config to fully rewrite `models.json`. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 2956d53133e..3ef08267618 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -372,11 +372,16 @@ openclaw secrets audit --check Findings include: -- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`) +- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`, and generated `agents/*/agent/models.json`) +- plaintext sensitive provider header residues in generated `models.json` entries - unresolved refs - precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs) - legacy residues (`auth.json`, OAuth reminders) +Header residue note: + +- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`). + ### `secrets configure` Interactive helper that: diff --git a/docs/help/faq.md b/docs/help/faq.md index 2ae55caf0c3..65a580dd965 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2503,7 +2503,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): -- The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`. +- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage. Fix: diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index d356e4f809e..dd1b5f1fd2f 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -23,6 +23,7 @@ Scope intent: [//]: # "secretref-supported-list-start" - `models.providers.*.apiKey` +- `models.providers.*.headers.*` - `skills.entries.*.apiKey` - `agents.defaults.memorySearch.remote.apiKey` - `agents.list[].memorySearch.remote.apiKey` @@ -98,6 +99,7 @@ Notes: - Auth-profile plan targets require `agentId`. - Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`). - Auth-profile refs are included in runtime resolution and audit coverage. +- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces. - For web search: - In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active. - In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active. diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index ac454a605a6..773ef8ab162 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -426,6 +426,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "models.providers.*.headers.*", + "configFile": "openclaw.json", + "path": "models.providers.*.headers.*", + "secretShape": "secret_input", + "optIn": true + }, { "id": "skills.entries.*.apiKey", "configFile": "openclaw.json", diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index a6bacc5f2a1..2e7a43bdecc 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -276,7 +276,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 3a5c86c360e..3e3401cad64 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -34,7 +34,7 @@ Security trust model: - By default, OpenClaw is a personal agent: one trusted operator boundary. - Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). -- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in. +- Local onboarding now defaults new configs to `tools.profile: "coding"` so fresh local setups keep filesystem/runtime tools without forcing the unrestricted `full` profile. - If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index f9ff309be54..44f470ea73b 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -247,7 +247,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 874dc4bf514..ef1fc52b31a 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -51,7 +51,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) - - Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved) + - Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved) - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) diff --git a/docs/tools/web.md b/docs/tools/web.md index c87638b8d86..3026f5ff1c5 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -55,7 +55,7 @@ Use `openclaw configure --section web` to set up your API key and choose a provi ### Perplexity Search -1. Create a Perplexity account at +1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) 2. Generate an API key in the dashboard 3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment. @@ -63,7 +63,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks ### Brave Search -1. Create a Brave Search API account at +1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/) 2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key. 3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment. @@ -104,7 +104,7 @@ Brave provides paid plans; check the Brave API portal for the current limits and search: { enabled: true, provider: "brave", - apiKey: "BSA...", // optional if BRAVE_API_KEY is set + apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret }, }, }, diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ff14af8c4cd..bbee9443b83 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://:18789 Optional one-time auth (if needed): ```text -http://localhost:5173/?gatewayUrl=wss://:18789&token= +http://localhost:5173/?gatewayUrl=wss://:18789#token= ``` Notes: - `gatewayUrl` is stored in localStorage after load and removed from the URL. -- `token` is stored in localStorage; `password` is kept in memory only. +- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage. +- `password` is kept in memory only. - When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 02e084ffdae..64780ef40d6 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -24,7 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth` (token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration). Security note: the Control UI is an **admin surface** (chat, config, exec approvals). -Do not expose it publicly. The UI stores the token in `localStorage` after first load. +Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab +and strips them from the URL after load. Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Fast path (recommended) @@ -36,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. ## Token basics (local vs remote) - **Localhost**: open `http://127.0.0.1:18789/`. -- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect. +- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, but the Control UI does not persist gateway tokens in localStorage. - If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. - If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index b64cabe63e9..b02019058b8 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2391,11 +2391,11 @@ describe("BlueBubbles webhook monitor", () => { }); const accountA: ResolvedBlueBubblesAccount = { - ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), + ...createMockAccount({ dmHistoryLimit: 3, password: "password-a" }), // pragma: allowlist secret accountId: "acc-a", }; const accountB: ResolvedBlueBubblesAccount = { - ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), + ...createMockAccount({ dmHistoryLimit: 3, password: "password-b" }), // pragma: allowlist secret accountId: "acc-b", }; const config: OpenClawConfig = {}; diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 201216c89ca..3f52a83d673 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -166,7 +166,7 @@ function createMockAccount( configured: true, config: { serverUrl: "http://localhost:1234", - password: "test-password", + password: "test-password", // pragma: allowlist secret dmPolicy: "open", groupPolicy: "open", allowFrom: [], @@ -240,15 +240,6 @@ function getFirstDispatchCall(): DispatchReplyParams { } describe("BlueBubbles webhook monitor", () => { - const WEBHOOK_PATH = "/bluebubbles-webhook"; - const BASE_WEBHOOK_MESSAGE_DATA = { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - } as const; - let unregister: () => void; beforeEach(() => { @@ -270,144 +261,122 @@ describe("BlueBubbles webhook monitor", () => { unregister?.(); }); - function createWebhookPayload( - dataOverrides: Record = {}, - ): Record { - return { - type: "new-message", - data: { - ...BASE_WEBHOOK_MESSAGE_DATA, - ...dataOverrides, - }, - }; - } - - function createWebhookTargetDeps(core?: PluginRuntime): { - config: OpenClawConfig; - core: PluginRuntime; - runtime: { - log: ReturnType void>>; - error: ReturnType void>>; - }; - } { - const resolvedCore = core ?? createMockRuntime(); - setBlueBubblesRuntime(resolvedCore); - return { - config: {}, - core: resolvedCore, - runtime: { - log: vi.fn<(message: string) => void>(), - error: vi.fn<(message: string) => void>(), - }, - }; - } - - function registerWebhookTarget( - params: { - account?: ResolvedBlueBubblesAccount; - config?: OpenClawConfig; - core?: PluginRuntime; - runtime?: { - log: ReturnType void>>; - error: ReturnType void>>; - }; - path?: string; - statusSink?: Parameters[0]["statusSink"]; - trackForCleanup?: boolean; - } = {}, - ): { - config: OpenClawConfig; - core: PluginRuntime; - runtime: { - log: ReturnType void>>; - error: ReturnType void>>; - }; - stop: () => void; - } { - const deps = - params.config && params.core && params.runtime - ? { config: params.config, core: params.core, runtime: params.runtime } - : createWebhookTargetDeps(params.core); - const stop = registerBlueBubblesWebhookTarget({ - account: params.account ?? createMockAccount(), - ...deps, - path: params.path ?? WEBHOOK_PATH, - statusSink: params.statusSink, - }); - if (params.trackForCleanup !== false) { - unregister = stop; - } - return { ...deps, stop }; - } - - async function sendWebhookRequest(params: { - method?: string; - url?: string; - body?: unknown; - headers?: Record; - remoteAddress?: string; - }): Promise<{ - req: IncomingMessage; - res: ServerResponse & { body: string; statusCode: number }; - handled: boolean; - }> { - const req = createMockRequest( - params.method ?? "POST", - params.url ?? WEBHOOK_PATH, - params.body ?? createWebhookPayload(), - params.headers, - ); - if (params.remoteAddress) { - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: params.remoteAddress, - }; - } - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - return { req, res, handled }; - } - describe("webhook parsing + auth handling", () => { it("rejects non-POST requests", async () => { - registerWebhookTarget(); - const { handled, res } = await sendWebhookRequest({ - method: "GET", - body: {}, + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); + const req = createMockRequest("GET", "/bluebubbles-webhook", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); expect(res.statusCode).toBe(405); }); it("accepts POST requests with valid JSON payload", async () => { - registerWebhookTarget(); - const { handled, res } = await sendWebhookRequest({ - body: createWebhookPayload({ date: Date.now() }), + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); expect(res.statusCode).toBe(200); expect(res.body).toBe("ok"); }); it("rejects requests with invalid JSON", async () => { - registerWebhookTarget(); - const { handled, res } = await sendWebhookRequest({ - body: "invalid json {{", + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); + const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{"); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); expect(res.statusCode).toBe(400); }); it("accepts URL-encoded payload wrappers", async () => { - registerWebhookTarget(); - const payload = createWebhookPayload({ date: Date.now() }); + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; const encodedBody = new URLSearchParams({ payload: JSON.stringify(payload), }).toString(); - const { handled, res } = await sendWebhookRequest({ body: encodedBody }); + const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(200); @@ -417,12 +386,23 @@ describe("BlueBubbles webhook monitor", () => { it("returns 408 when request body times out (Slow-Loris protection)", async () => { vi.useFakeTimers(); try { - registerWebhookTarget(); + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); // Create a request that never sends data or ends (simulates slow-loris) const req = new EventEmitter() as IncomingMessage; req.method = "POST"; - req.url = `${WEBHOOK_PATH}?password=test-password`; + req.url = "/bluebubbles-webhook?password=test-password"; req.headers = {}; (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1", @@ -446,13 +426,22 @@ describe("BlueBubbles webhook monitor", () => { }); it("rejects unauthorized requests before reading the body", async () => { - registerWebhookTarget({ - account: createMockAccount({ password: "secret-token" }), + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); const req = new EventEmitter() as IncomingMessage; req.method = "POST"; - req.url = `${WEBHOOK_PATH}?password=wrong-token`; + req.url = "/bluebubbles-webhook?password=wrong-token"; req.headers = {}; const onSpy = vi.spyOn(req, "on"); (req as unknown as { socket: { remoteAddress: string } }).socket = { @@ -468,43 +457,112 @@ describe("BlueBubbles webhook monitor", () => { }); it("authenticates via password query parameter", async () => { - registerWebhookTarget({ - account: createMockAccount({ password: "secret-token" }), + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + // Mock non-localhost request + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, }); - const { handled, res } = await sendWebhookRequest({ - url: `${WEBHOOK_PATH}?password=secret-token`, - body: createWebhookPayload(), + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); expect(res.statusCode).toBe(200); }); it("authenticates via x-password header", async () => { - registerWebhookTarget({ - account: createMockAccount({ password: "secret-token" }), - }); - const { handled, res } = await sendWebhookRequest({ - body: createWebhookPayload(), - headers: { "x-password": "secret-token" }, + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, + { "x-password": "secret-token" }, // pragma: allowlist secret + ); + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); expect(res.statusCode).toBe(200); }); it("rejects unauthorized requests with wrong password", async () => { - registerWebhookTarget({ - account: createMockAccount({ password: "secret-token" }), + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, }); - const { handled, res } = await sendWebhookRequest({ - url: `${WEBHOOK_PATH}?password=wrong-token`, - body: createWebhookPayload(), + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + expect(handled).toBe(true); expect(res.statusCode).toBe(401); }); @@ -512,37 +570,50 @@ describe("BlueBubbles webhook monitor", () => { it("rejects ambiguous routing when multiple targets match the same password", async () => { const accountA = createMockAccount({ password: "secret-token" }); const accountB = createMockAccount({ password: "secret-token" }); - const { config, core, runtime } = createWebhookTargetDeps(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); const sinkA = vi.fn(); const sinkB = vi.fn(); - const unregisterA = registerWebhookTarget({ + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + const unregisterA = registerBlueBubblesWebhookTarget({ account: accountA, config, - runtime, + runtime: { log: vi.fn(), error: vi.fn() }, core, - trackForCleanup: false, + path: "/bluebubbles-webhook", statusSink: sinkA, - }).stop; - const unregisterB = registerWebhookTarget({ + }); + const unregisterB = registerBlueBubblesWebhookTarget({ account: accountB, config, - runtime, + runtime: { log: vi.fn(), error: vi.fn() }, core, - trackForCleanup: false, + path: "/bluebubbles-webhook", statusSink: sinkB, - }).stop; + }); unregister = () => { unregisterA(); unregisterB(); }; - const { handled, res } = await sendWebhookRequest({ - url: `${WEBHOOK_PATH}?password=secret-token`, - body: createWebhookPayload(), - remoteAddress: "192.168.1.100", - }); + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(401); @@ -553,37 +624,50 @@ describe("BlueBubbles webhook monitor", () => { it("ignores targets without passwords when a password-authenticated target matches", async () => { const accountStrict = createMockAccount({ password: "secret-token" }); const accountWithoutPassword = createMockAccount({ password: undefined }); - const { config, core, runtime } = createWebhookTargetDeps(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); const sinkStrict = vi.fn(); const sinkWithoutPassword = vi.fn(); - const unregisterStrict = registerWebhookTarget({ + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + const unregisterStrict = registerBlueBubblesWebhookTarget({ account: accountStrict, config, - runtime, + runtime: { log: vi.fn(), error: vi.fn() }, core, - trackForCleanup: false, + path: "/bluebubbles-webhook", statusSink: sinkStrict, - }).stop; - const unregisterNoPassword = registerWebhookTarget({ + }); + const unregisterNoPassword = registerBlueBubblesWebhookTarget({ account: accountWithoutPassword, config, - runtime, + runtime: { log: vi.fn(), error: vi.fn() }, core, - trackForCleanup: false, + path: "/bluebubbles-webhook", statusSink: sinkWithoutPassword, - }).stop; + }); unregister = () => { unregisterStrict(); unregisterNoPassword(); }; - const { handled, res } = await sendWebhookRequest({ - url: `${WEBHOOK_PATH}?password=secret-token`, - body: createWebhookPayload(), - remoteAddress: "192.168.1.100", - }); + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(200); @@ -593,20 +677,34 @@ describe("BlueBubbles webhook monitor", () => { it("requires authentication for loopback requests when password is configured", async () => { const account = createMockAccount({ password: "secret-token" }); - const { config, core, runtime } = createWebhookTargetDeps(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { - const loopbackUnregister = registerWebhookTarget({ + const req = createMockRequest("POST", "/bluebubbles-webhook", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress, + }; + + const loopbackUnregister = registerBlueBubblesWebhookTarget({ account, config, - runtime, + runtime: { log: vi.fn(), error: vi.fn() }, core, - trackForCleanup: false, - }).stop; - - const { handled, res } = await sendWebhookRequest({ - body: createWebhookPayload(), - remoteAddress, + path: "/bluebubbles-webhook", }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(401); @@ -615,8 +713,17 @@ describe("BlueBubbles webhook monitor", () => { }); it("rejects targets without passwords for loopback and proxied-looking requests", async () => { - registerWebhookTarget({ - account: createMockAccount({ password: undefined }), + const account = createMockAccount({ password: undefined }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); const headerVariants: Record[] = [ @@ -625,11 +732,26 @@ describe("BlueBubbles webhook monitor", () => { { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, ]; for (const headers of headerVariants) { - const { handled, res } = await sendWebhookRequest({ - body: createWebhookPayload(), + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, headers, + ); + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1", - }); + }; + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); expect(handled).toBe(true); expect(res.statusCode).toBe(401); } @@ -648,18 +770,36 @@ describe("BlueBubbles webhook monitor", () => { const { resolveChatGuidForTarget } = await import("./send.js"); vi.mocked(resolveChatGuidForTarget).mockClear(); - registerWebhookTarget({ - account: createMockAccount({ groupPolicy: "open" }), + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); - await sendWebhookRequest({ - body: createWebhookPayload({ + const payload = { + type: "new-message", + data: { text: "hello from group", + handle: { address: "+15551234567" }, isGroup: true, + isFromMe: false, + guid: "msg-1", chatId: "123", date: Date.now(), - }), - }); + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(resolveChatGuidForTarget).toHaveBeenCalledWith( @@ -679,18 +819,36 @@ describe("BlueBubbles webhook monitor", () => { return EMPTY_DISPATCH_RESULT; }); - registerWebhookTarget({ - account: createMockAccount({ groupPolicy: "open" }), + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", }); - await sendWebhookRequest({ - body: createWebhookPayload({ + const payload = { + type: "new-message", + data: { text: "hello from group", + handle: { address: "+15551234567" }, isGroup: true, + isFromMe: false, + guid: "msg-1", chat: { chatGuid: "iMessage;+;chat123456" }, date: Date.now(), - }), - }); + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); await flushAsync(); expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); diff --git a/extensions/bluebubbles/src/secret-input.ts b/extensions/bluebubbles/src/secret-input.ts index 8a5530f4607..a5aa73ebda0 100644 --- a/extensions/bluebubbles/src/secret-input.ts +++ b/extensions/bluebubbles/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/bluebubbles"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index e77d1f3cabe..d310b227be3 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -329,13 +329,13 @@ describe("diagnostics-otel service", () => { test("redacts sensitive data from log attributes before export", async () => { const emitCall = await emitAndCaptureLog({ - 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', // pragma: allowlist secret 1: "auth configured", _meta: { logLevelName: "DEBUG", date: new Date() }, }); const tokenAttr = emitCall?.attributes?.["openclaw.token"]; - expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); // pragma: allowlist secret if (typeof tokenAttr === "string") { expect(tokenAttr).toContain("…"); } @@ -349,7 +349,7 @@ describe("diagnostics-otel service", () => { emitDiagnosticEvent({ type: "session.state", state: "waiting", - reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", // pragma: allowlist secret }); const sessionCounter = telemetryState.counters.get("openclaw.session.state"); @@ -362,7 +362,7 @@ describe("diagnostics-otel service", () => { const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; expect(typeof attrs?.["openclaw.reason"]).toBe("string"); expect(String(attrs?.["openclaw.reason"])).not.toContain( - "ghp_abcdefghijklmnopqrstuvwxyz123456", + "ghp_abcdefghijklmnopqrstuvwxyz123456", // pragma: allowlist secret ); await service.stop?.(ctx); }); diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index b9a0fee6e59..5e8c2927691 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -135,6 +135,29 @@ describe("createDiffsHttpHandler", () => { expect(res.statusCode).toBe(404); }); + it("blocks loopback requests that carry proxy forwarding headers by default", async () => { + const artifact = await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); + + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: artifact.viewerPath, + headers: { "x-forwarded-for": "203.0.113.10" }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(404); + }); + it("allows remote access when allowRemoteViewer is enabled", async () => { const artifact = await store.createArtifact({ html: "viewer", @@ -158,6 +181,30 @@ describe("createDiffsHttpHandler", () => { expect(res.body).toBe("viewer"); }); + it("allows proxied loopback requests when allowRemoteViewer is enabled", async () => { + const artifact = await store.createArtifact({ + html: "viewer", + title: "Demo", + inputKind: "before_after", + fileCount: 1, + }); + + const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url: artifact.viewerPath, + headers: { "x-forwarded-for": "203.0.113.10" }, + }), + res, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("viewer"); + }); + it("rate-limits repeated remote misses", async () => { const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true }); @@ -185,16 +232,26 @@ describe("createDiffsHttpHandler", () => { }); }); -function localReq(input: { method: string; url: string }): IncomingMessage { +function localReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "127.0.0.1" }, } as unknown as IncomingMessage; } -function remoteReq(input: { method: string; url: string }): IncomingMessage { +function remoteReq(input: { + method: string; + url: string; + headers?: Record; +}): IncomingMessage { return { ...input, + headers: input.headers ?? {}, socket: { remoteAddress: "203.0.113.10" }, } as unknown as IncomingMessage; } diff --git a/extensions/diffs/src/http.ts b/extensions/diffs/src/http.ts index 0f17e77fd9e..445500b2340 100644 --- a/extensions/diffs/src/http.ts +++ b/extensions/diffs/src/http.ts @@ -42,9 +42,8 @@ export function createDiffsHttpHandler(params: { return false; } - const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); - const localRequest = isLoopbackClientIp(remoteKey); - if (!localRequest && params.allowRemoteViewer !== true) { + const access = resolveViewerAccess(req); + if (!access.localRequest && params.allowRemoteViewer !== true) { respondText(res, 404, "Diff not found"); return true; } @@ -54,8 +53,8 @@ export function createDiffsHttpHandler(params: { return true; } - if (!localRequest) { - const throttled = viewerFailureLimiter.check(remoteKey); + if (!access.localRequest) { + const throttled = viewerFailureLimiter.check(access.remoteKey); if (!throttled.allowed) { res.statusCode = 429; setSharedHeaders(res, "text/plain; charset=utf-8"); @@ -74,27 +73,21 @@ export function createDiffsHttpHandler(params: { !DIFF_ARTIFACT_ID_PATTERN.test(id) || !DIFF_ARTIFACT_TOKEN_PATTERN.test(token) ) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); respondText(res, 404, "Diff not found"); return true; } const artifact = await params.store.getArtifact(id, token); if (!artifact) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); respondText(res, 404, "Diff not found or expired"); return true; } try { const html = await params.store.readHtml(id); - if (!localRequest) { - viewerFailureLimiter.reset(remoteKey); - } + resetRemoteFailures(viewerFailureLimiter, access); res.statusCode = 200; setSharedHeaders(res, "text/html; charset=utf-8"); res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY); @@ -105,9 +98,7 @@ export function createDiffsHttpHandler(params: { } return true; } catch (error) { - if (!localRequest) { - viewerFailureLimiter.recordFailure(remoteKey); - } + recordRemoteFailure(viewerFailureLimiter, access); params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`); respondText(res, 500, "Failed to load diff"); return true; @@ -184,6 +175,44 @@ function isLoopbackClientIp(clientIp: string): boolean { return clientIp === "127.0.0.1" || clientIp === "::1"; } +function hasProxyForwardingHints(req: IncomingMessage): boolean { + const headers = req.headers ?? {}; + return Boolean( + headers["x-forwarded-for"] || + headers["x-real-ip"] || + headers.forwarded || + headers["x-forwarded-host"] || + headers["x-forwarded-proto"], + ); +} + +function resolveViewerAccess(req: IncomingMessage): { + remoteKey: string; + localRequest: boolean; +} { + const remoteKey = normalizeRemoteClientKey(req.socket?.remoteAddress); + const localRequest = isLoopbackClientIp(remoteKey) && !hasProxyForwardingHints(req); + return { remoteKey, localRequest }; +} + +function recordRemoteFailure( + limiter: ViewerFailureLimiter, + access: { remoteKey: string; localRequest: boolean }, +): void { + if (!access.localRequest) { + limiter.recordFailure(access.remoteKey); + } +} + +function resetRemoteFailures( + limiter: ViewerFailureLimiter, + access: { remoteKey: string; localRequest: boolean }, +): void { + if (!access.localRequest) { + limiter.reset(access.remoteKey); + } +} + type RateLimitCheckResult = { allowed: boolean; retryAfterMs: number; diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index bc04d4c56c2..979f2fa3791 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -9,6 +9,35 @@ import type { FeishuConfig } from "./types.js"; const asConfig = (value: Partial) => value as FeishuConfig; +function withEnvVar(key: string, value: string | undefined, run: () => void) { + const prev = process.env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + try { + run(); + } finally { + if (prev === undefined) { + delete process.env[key]; + } else { + process.env[key] = prev; + } + } +} + +function expectUnresolvedEnvSecretRefError(key: string) { + expect(() => + resolveFeishuCredentials( + asConfig({ + appId: "cli_123", + appSecret: { source: "env", provider: "default", id: key } as never, + }), + ), + ).toThrow(/unresolved SecretRef/i); +} + describe("resolveDefaultFeishuAccountId", () => { it("prefers channels.feishu.defaultAccount when configured", () => { const cfg = { @@ -16,8 +45,8 @@ describe("resolveDefaultFeishuAccountId", () => { feishu: { defaultAccount: "router-d", accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }, }, @@ -32,7 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => { feishu: { defaultAccount: "Router D", accounts: { - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }, }, @@ -47,8 +76,8 @@ describe("resolveDefaultFeishuAccountId", () => { feishu: { defaultAccount: "router-d", accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret }, }, }, @@ -62,8 +91,8 @@ describe("resolveDefaultFeishuAccountId", () => { channels: { feishu: { accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + zeta: { appId: "cli_zeta", appSecret: "secret_zeta" }, // pragma: allowlist secret }, }, }, @@ -90,7 +119,7 @@ describe("resolveDefaultFeishuAccountId", () => { channels: { feishu: { accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret }, }, }, @@ -128,24 +157,9 @@ describe("resolveFeishuCredentials", () => { it("throws unresolved SecretRef error when env SecretRef points to missing env var", () => { const key = "FEISHU_APP_SECRET_MISSING_TEST"; - const prev = process.env[key]; - delete process.env[key]; - try { - expect(() => - resolveFeishuCredentials( - asConfig({ - appId: "cli_123", - appSecret: { source: "env", provider: "default", id: key } as never, - }), - ), - ).toThrow(/unresolved SecretRef/i); - } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } - } + withEnvVar(key, undefined, () => { + expectUnresolvedEnvSecretRefError(key); + }); }); it("resolves env SecretRef objects when unresolved refs are allowed", () => { @@ -164,7 +178,7 @@ describe("resolveFeishuCredentials", () => { expect(creds).toEqual({ appId: "cli_123", - appSecret: "secret_from_env", + appSecret: "secret_from_env", // pragma: allowlist secret encryptKey: undefined, verificationToken: undefined, domain: "feishu", @@ -204,24 +218,9 @@ describe("resolveFeishuCredentials", () => { it("preserves unresolved SecretRef diagnostics for env refs in default mode", () => { const key = "FEISHU_APP_SECRET_POLICY_TEST"; - const prev = process.env[key]; - process.env[key] = "secret_from_env"; - try { - expect(() => - resolveFeishuCredentials( - asConfig({ - appId: "cli_123", - appSecret: { source: "env", provider: "default", id: key } as never, - }), - ), - ).toThrow(/unresolved SecretRef/i); - } finally { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } - } + withEnvVar(key, "secret_from_env", () => { + expectUnresolvedEnvSecretRefError(key); + }); }); it("trims and returns credentials when values are valid strings", () => { @@ -236,7 +235,7 @@ describe("resolveFeishuCredentials", () => { expect(creds).toEqual({ appId: "cli_123", - appSecret: "secret_456", + appSecret: "secret_456", // pragma: allowlist secret encryptKey: "enc", verificationToken: "vt", domain: "feishu", @@ -251,9 +250,9 @@ describe("resolveFeishuAccount", () => { feishu: { defaultAccount: "router-d", appId: "top_level_app", - appSecret: "top_level_secret", + appSecret: "top_level_secret", // pragma: allowlist secret accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret }, }, }, @@ -273,7 +272,7 @@ describe("resolveFeishuAccount", () => { defaultAccount: "router-d", accounts: { default: { enabled: true }, - "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, + "router-d": { appId: "cli_router", appSecret: "secret_router", enabled: true }, // pragma: allowlist secret }, }, }, @@ -292,8 +291,8 @@ describe("resolveFeishuAccount", () => { feishu: { defaultAccount: "router-d", accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }, }, @@ -335,7 +334,7 @@ describe("resolveFeishuAccount", () => { main: { name: { bad: true }, appId: "cli_123", - appSecret: "secret_456", + appSecret: "secret_456", // pragma: allowlist secret } as never, }, }, diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index f4ea7dd4e08..2da6bcc2c6f 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1088,7 +1088,7 @@ describe("handleFeishuMessage command authorization", () => { channels: { feishu: { appId: "cli_test", - appSecret: "sec_test", + appSecret: "sec_test", // pragma: allowlist secret groups: { "oc-group": { requireMention: false, @@ -1151,7 +1151,7 @@ describe("handleFeishuMessage command authorization", () => { channels: { feishu: { appId: "cli_scope_bug", - appSecret: "sec_scope_bug", + appSecret: "sec_scope_bug", // pragma: allowlist secret groups: { "oc-group": { requireMention: false, diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index 631944fa18f..9ebf579f962 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -29,7 +29,7 @@ describe("registerFeishuChatTools", () => { feishu: { enabled: true, appId: "app_id", - appSecret: "app_secret", + appSecret: "app_secret", // pragma: allowlist secret tools: { chat: true }, }, }, @@ -76,7 +76,7 @@ describe("registerFeishuChatTools", () => { feishu: { enabled: true, appId: "app_id", - appSecret: "app_secret", + appSecret: "app_secret", // pragma: allowlist secret tools: { chat: false }, }, }, diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index a5855fa0745..ccaf6ea6d0d 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -59,7 +59,7 @@ const baseAccount: ResolvedFeishuAccount = { enabled: true, configured: true, appId: "app_123", - appSecret: "secret_123", + appSecret: "secret_123", // pragma: allowlist secret domain: "feishu", config: {} as FeishuConfig, }; @@ -101,8 +101,26 @@ describe("createFeishuClient HTTP timeout", () => { clearClientCache(); }); + const getLastClientHttpInstance = () => { + const calls = (LarkClient as unknown as ReturnType).mock.calls; + const lastCall = calls[calls.length - 1]?.[0] as + | { httpInstance?: { get: (...args: unknown[]) => Promise } } + | undefined; + return lastCall?.httpInstance; + }; + + const expectGetCallTimeout = async (timeout: number) => { + const httpInstance = getLastClientHttpInstance(); + expect(httpInstance).toBeDefined(); + await httpInstance?.get("https://example.com/api"); + expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( + "https://example.com/api", + expect.objectContaining({ timeout }), + ); + }; + it("passes a custom httpInstance with default timeout to Lark.Client", () => { - createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); + createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret const calls = (LarkClient as unknown as ReturnType).mock.calls; const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown }; @@ -110,7 +128,7 @@ describe("createFeishuClient HTTP timeout", () => { }); it("injects default timeout into HTTP request options", async () => { - createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); + createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret const calls = (LarkClient as unknown as ReturnType).mock.calls; const lastCall = calls[calls.length - 1][0] as { @@ -132,7 +150,7 @@ describe("createFeishuClient HTTP timeout", () => { }); it("allows explicit timeout override per-request", async () => { - createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); + createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret const calls = (LarkClient as unknown as ReturnType).mock.calls; const lastCall = calls[calls.length - 1][0] as { @@ -151,45 +169,23 @@ describe("createFeishuClient HTTP timeout", () => { it("uses config-configured default timeout when provided", async () => { createFeishuClient({ appId: "app_4", - appSecret: "secret_4", + appSecret: "secret_4", // pragma: allowlist secret accountId: "timeout-config", config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; - - await httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: 45_000 }), - ); + await expectGetCallTimeout(45_000); }); it("falls back to default timeout when configured timeout is invalid", async () => { createFeishuClient({ appId: "app_5", - appSecret: "secret_5", + appSecret: "secret_5", // pragma: allowlist secret accountId: "timeout-config-invalid", config: { httpTimeoutMs: -1 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - const httpInstance = lastCall.httpInstance; - - await httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MS }), - ); + await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MS); }); it("uses env timeout override when provided and no direct timeout is set", async () => { @@ -197,21 +193,12 @@ describe("createFeishuClient HTTP timeout", () => { createFeishuClient({ appId: "app_8", - appSecret: "secret_8", + appSecret: "secret_8", // pragma: allowlist secret accountId: "timeout-env-override", config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: 60_000 }), - ); + await expectGetCallTimeout(60_000); }); it("prefers direct timeout over env override", async () => { @@ -219,22 +206,13 @@ describe("createFeishuClient HTTP timeout", () => { createFeishuClient({ appId: "app_10", - appSecret: "secret_10", + appSecret: "secret_10", // pragma: allowlist secret accountId: "timeout-direct-override", httpTimeoutMs: 120_000, config: { httpTimeoutMs: 45_000 }, }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: 120_000 }), - ); + await expectGetCallTimeout(120_000); }); it("clamps env timeout override to max bound", async () => { @@ -242,32 +220,23 @@ describe("createFeishuClient HTTP timeout", () => { createFeishuClient({ appId: "app_9", - appSecret: "secret_9", + appSecret: "secret_9", // pragma: allowlist secret accountId: "timeout-env-clamp", }); - const calls = (LarkClient as unknown as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1][0] as { - httpInstance: { get: (...args: unknown[]) => Promise }; - }; - await lastCall.httpInstance.get("https://example.com/api"); - - expect(mockBaseHttpInstance.get).toHaveBeenCalledWith( - "https://example.com/api", - expect.objectContaining({ timeout: FEISHU_HTTP_TIMEOUT_MAX_MS }), - ); + await expectGetCallTimeout(FEISHU_HTTP_TIMEOUT_MAX_MS); }); it("recreates cached client when configured timeout changes", async () => { createFeishuClient({ appId: "app_6", - appSecret: "secret_6", + appSecret: "secret_6", // pragma: allowlist secret accountId: "timeout-cache-change", config: { httpTimeoutMs: 30_000 }, }); createFeishuClient({ appId: "app_6", - appSecret: "secret_6", + appSecret: "secret_6", // pragma: allowlist secret accountId: "timeout-cache-change", config: { httpTimeoutMs: 45_000 }, }); diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 035f89a2940..cdd4724d3fb 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -36,7 +36,7 @@ describe("FeishuConfigSchema webhook validation", () => { const result = FeishuConfigSchema.safeParse({ connectionMode: "webhook", appId: "cli_top", - appSecret: "secret_top", + appSecret: "secret_top", // pragma: allowlist secret }); expect(result.success).toBe(false); @@ -52,7 +52,7 @@ describe("FeishuConfigSchema webhook validation", () => { connectionMode: "webhook", verificationToken: "token_top", appId: "cli_top", - appSecret: "secret_top", + appSecret: "secret_top", // pragma: allowlist secret }); expect(result.success).toBe(true); @@ -64,7 +64,7 @@ describe("FeishuConfigSchema webhook validation", () => { main: { connectionMode: "webhook", appId: "cli_main", - appSecret: "secret_main", + appSecret: "secret_main", // pragma: allowlist secret }, }, }); @@ -86,7 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => { main: { connectionMode: "webhook", appId: "cli_main", - appSecret: "secret_main", + appSecret: "secret_main", // pragma: allowlist secret }, }, }); @@ -171,7 +171,7 @@ describe("FeishuConfigSchema defaultAccount", () => { const result = FeishuConfigSchema.safeParse({ defaultAccount: "router-d", accounts: { - "router-d": { appId: "cli_router", appSecret: "secret_router" }, + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret }, }); @@ -182,7 +182,7 @@ describe("FeishuConfigSchema defaultAccount", () => { const result = FeishuConfigSchema.safeParse({ defaultAccount: "router-d", accounts: { - backup: { appId: "cli_backup", appSecret: "secret_backup" }, + backup: { appId: "cli_backup", appSecret: "secret_backup" }, // pragma: allowlist secret }, }); diff --git a/extensions/feishu/src/docx-batch-insert.test.ts b/extensions/feishu/src/docx-batch-insert.test.ts new file mode 100644 index 00000000000..239e46738b4 --- /dev/null +++ b/extensions/feishu/src/docx-batch-insert.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js"; + +function createCountingIterable(values: T[]) { + let iterations = 0; + return { + values: { + [Symbol.iterator]: function* () { + iterations += 1; + yield* values; + }, + }, + getIterations: () => iterations, + }; +} + +describe("insertBlocksInBatches", () => { + it("builds the source block map once for large flat trees", async () => { + const blockCount = BATCH_SIZE + 200; + const blocks = Array.from({ length: blockCount }, (_, index) => ({ + block_id: `block_${index}`, + block_type: 2, + })); + const counting = createCountingIterable(blocks); + const createMock = vi.fn(async ({ data }: { data: { children_id: string[] } }) => ({ + code: 0, + data: { + children: data.children_id.map((id) => ({ block_id: id })), + }, + })); + const client = { + docx: { + documentBlockDescendant: { + create: createMock, + }, + }, + } as any; + + const result = await insertBlocksInBatches( + client, + "doc_1", + counting.values as any[], + blocks.map((block) => block.block_id), + ); + + expect(counting.getIterations()).toBe(1); + expect(createMock).toHaveBeenCalledTimes(2); + expect(createMock.mock.calls[0]?.[0]?.data.children_id).toHaveLength(BATCH_SIZE); + expect(createMock.mock.calls[1]?.[0]?.data.children_id).toHaveLength(200); + expect(result.children).toHaveLength(blockCount); + }); + + it("keeps nested descendants grouped with their root blocks", async () => { + const createMock = vi.fn( + async ({ + data, + }: { + data: { children_id: string[]; descendants: Array<{ block_id: string }> }; + }) => ({ + code: 0, + data: { + children: data.children_id.map((id) => ({ block_id: id })), + }, + }), + ); + const client = { + docx: { + documentBlockDescendant: { + create: createMock, + }, + }, + } as any; + const blocks = [ + { block_id: "root_a", block_type: 1, children: ["child_a"] }, + { block_id: "child_a", block_type: 2 }, + { block_id: "root_b", block_type: 1, children: ["child_b"] }, + { block_id: "child_b", block_type: 2 }, + ]; + + await insertBlocksInBatches(client, "doc_1", blocks as any[], ["root_a", "root_b"]); + + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0]?.[0]?.data.children_id).toEqual(["root_a", "root_b"]); + expect( + createMock.mock.calls[0]?.[0]?.data.descendants.map( + (block: { block_id: string }) => block.block_id, + ), + ).toEqual(["root_a", "child_a", "root_b", "child_b"]); + }); +}); diff --git a/extensions/feishu/src/docx-batch-insert.ts b/extensions/feishu/src/docx-batch-insert.ts index e38552a4857..b855e53a4a9 100644 --- a/extensions/feishu/src/docx-batch-insert.ts +++ b/extensions/feishu/src/docx-batch-insert.ts @@ -14,16 +14,11 @@ export const BATCH_SIZE = 1000; // Feishu API limit per request type Logger = { info?: (msg: string) => void }; /** - * Collect all descendant blocks for a given set of first-level block IDs. + * Collect all descendant blocks for a given first-level block ID. * Recursively traverses the block tree to gather all children. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types -function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] { - const blockMap = new Map(); - for (const block of blocks) { - blockMap.set(block.block_id, block); - } - +function collectDescendants(blockMap: Map, rootId: string): any[] { const result: any[] = []; const visited = new Set(); @@ -47,9 +42,7 @@ function collectDescendants(blocks: any[], firstLevelIds: string[]): any[] { } } - for (const id of firstLevelIds) { - collect(id); - } + collect(rootId); return result; } @@ -123,9 +116,13 @@ export async function insertBlocksInBatches( const batches: { firstLevelIds: string[]; blocks: any[] }[] = []; let currentBatch: { firstLevelIds: string[]; blocks: any[] } = { firstLevelIds: [], blocks: [] }; const usedBlockIds = new Set(); + const blockMap = new Map(); + for (const block of blocks) { + blockMap.set(block.block_id, block); + } for (const firstLevelId of firstLevelBlockIds) { - const descendants = collectDescendants(blocks, [firstLevelId]); + const descendants = collectDescendants(blockMap, firstLevelId); const newBlocks = descendants.filter((b) => !usedBlockIds.has(b.block_id)); // A single block whose subtree exceeds the API limit cannot be split diff --git a/extensions/feishu/src/docx.account-selection.test.ts b/extensions/feishu/src/docx.account-selection.test.ts index 18b4083e324..1f11e290815 100644 --- a/extensions/feishu/src/docx.account-selection.test.ts +++ b/extensions/feishu/src/docx.account-selection.test.ts @@ -27,8 +27,8 @@ describe("feishu_doc account selection", () => { feishu: { enabled: true, accounts: { - a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, - b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, + a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } }, // pragma: allowlist secret + b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } }, // pragma: allowlist secret }, }, }, diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index cc64291b4ef..466b9a4201a 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -73,7 +73,7 @@ function buildConfig(params: { [params.accountId]: { enabled: true, appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret connectionMode: "webhook", webhookHost: "127.0.0.1", webhookPort: params.port, diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index dbb71448508..d3ace4faae0 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -17,6 +17,44 @@ const baseStatusContext = { accountOverrides: {}, }; +async function withEnvVars(values: Record, run: () => Promise) { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + await run(); + } finally { + for (const [key, prior] of previous.entries()) { + if (prior === undefined) { + delete process.env[key]; + } else { + process.env[key] = prior; + } + } + } +} + +async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) { + return await feishuOnboardingAdapter.getStatus({ + cfg: { + channels: { + feishu: { + appId: { source: "env", id: params.appIdKey, provider: "default" }, + appSecret: { source: "env", id: params.appSecretKey, provider: "default" }, + }, + }, + } as never, + ...baseStatusContext, + }); +} + describe("feishuOnboardingAdapter.configure", () => { it("does not throw when config appId/appSecret are SecretRef objects", async () => { const text = vi @@ -61,7 +99,7 @@ describe("feishuOnboardingAdapter.getStatus", () => { accounts: { main: { appId: "", - appSecret: "secret_123", + appSecret: "sample-app-credential", // pragma: allowlist secret }, }, }, @@ -75,73 +113,31 @@ describe("feishuOnboardingAdapter.getStatus", () => { it("treats env SecretRef appId as not configured when env var is missing", async () => { const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST"; - const appSecretKey = "FEISHU_APP_SECRET_STATUS_MISSING_TEST"; - const prevAppId = process.env[appIdKey]; - const prevAppSecret = process.env[appSecretKey]; - delete process.env[appIdKey]; - process.env[appSecretKey] = "secret_env_456"; - - try { - const status = await feishuOnboardingAdapter.getStatus({ - cfg: { - channels: { - feishu: { - appId: { source: "env", id: appIdKey, provider: "default" }, - appSecret: { source: "env", id: appSecretKey, provider: "default" }, - }, - }, - } as never, - ...baseStatusContext, - }); - - expect(status.configured).toBe(false); - } finally { - if (prevAppId === undefined) { - delete process.env[appIdKey]; - } else { - process.env[appIdKey] = prevAppId; - } - if (prevAppSecret === undefined) { - delete process.env[appSecretKey]; - } else { - process.env[appSecretKey] = prevAppSecret; - } - } + const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret + await withEnvVars( + { + [appIdKey]: undefined, + [appSecretKey]: "env-credential-456", // pragma: allowlist secret + }, + async () => { + const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey }); + expect(status.configured).toBe(false); + }, + ); }); it("treats env SecretRef appId/appSecret as configured in status", async () => { const appIdKey = "FEISHU_APP_ID_STATUS_TEST"; - const appSecretKey = "FEISHU_APP_SECRET_STATUS_TEST"; - const prevAppId = process.env[appIdKey]; - const prevAppSecret = process.env[appSecretKey]; - process.env[appIdKey] = "cli_env_123"; - process.env[appSecretKey] = "secret_env_456"; - - try { - const status = await feishuOnboardingAdapter.getStatus({ - cfg: { - channels: { - feishu: { - appId: { source: "env", id: appIdKey, provider: "default" }, - appSecret: { source: "env", id: appSecretKey, provider: "default" }, - }, - }, - } as never, - ...baseStatusContext, - }); - - expect(status.configured).toBe(true); - } finally { - if (prevAppId === undefined) { - delete process.env[appIdKey]; - } else { - process.env[appIdKey] = prevAppId; - } - if (prevAppSecret === undefined) { - delete process.env[appSecretKey]; - } else { - process.env[appSecretKey] = prevAppSecret; - } - } + const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_TEST"; // pragma: allowlist secret + await withEnvVars( + { + [appIdKey]: "cli_env_123", + [appSecretKey]: "env-credential-456", // pragma: allowlist secret + }, + async () => { + const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey }); + expect(status.configured).toBe(true); + }, + ); }); }); diff --git a/extensions/feishu/src/probe.test.ts b/extensions/feishu/src/probe.test.ts index e46929959b6..b93935cccc6 100644 --- a/extensions/feishu/src/probe.test.ts +++ b/extensions/feishu/src/probe.test.ts @@ -34,7 +34,7 @@ describe("probeFeishu", () => { }); it("returns error when appId is missing", async () => { - const result = await probeFeishu({ appSecret: "secret" } as never); + const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" }); }); @@ -49,7 +49,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); + const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(result).toEqual({ ok: true, appId: "cli_123", @@ -65,7 +65,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - await probeFeishu({ appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledWith( expect.objectContaining({ @@ -98,7 +98,7 @@ describe("probeFeishu", () => { abortController.abort(); const result = await probeFeishu( - { appId: "cli_123", appSecret: "secret" }, + { appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret { abortSignal: abortController.signal }, ); @@ -111,7 +111,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret const first = await probeFeishu(creds); const second = await probeFeishu(creds); @@ -128,7 +128,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret await probeFeishu(creds); expect(requestFn).toHaveBeenCalledTimes(1); @@ -148,7 +148,7 @@ describe("probeFeishu", () => { const requestFn = makeRequestFn({ code: 99, msg: "token expired" }); createFeishuClientMock.mockReturnValue({ request: requestFn }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret const first = await probeFeishu(creds); const second = await probeFeishu(creds); expect(first).toMatchObject({ ok: false, error: "API error: token expired" }); @@ -170,7 +170,7 @@ describe("probeFeishu", () => { const requestFn = vi.fn().mockRejectedValue(new Error("network error")); createFeishuClientMock.mockReturnValue({ request: requestFn }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret const first = await probeFeishu(creds); const second = await probeFeishu(creds); expect(first).toMatchObject({ ok: false, error: "network error" }); @@ -192,15 +192,15 @@ describe("probeFeishu", () => { bot: { bot_name: "Bot1", open_id: "ou_1" }, }); - await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); + await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); // Different appId should trigger a new API call - await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); + await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); // Same appId + appSecret as first call should return cached - await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); + await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); }); @@ -211,12 +211,12 @@ describe("probeFeishu", () => { }); // First account with appId + secret A - await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); + await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); // Second account with same appId but different secret (e.g. after rotation) // must NOT reuse the cached result - await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); + await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); }); @@ -227,14 +227,14 @@ describe("probeFeishu", () => { }); // Two accounts with same appId+appSecret but different accountIds are cached separately - await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(1); - await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); // Same accountId should return cached - await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); + await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(requestFn).toHaveBeenCalledTimes(2); }); @@ -244,7 +244,7 @@ describe("probeFeishu", () => { bot: { bot_name: "TestBot", open_id: "ou_abc123" }, }); - const creds = { appId: "cli_123", appSecret: "secret" }; + const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret await probeFeishu(creds); expect(requestFn).toHaveBeenCalledTimes(1); @@ -260,7 +260,7 @@ describe("probeFeishu", () => { data: { bot: { bot_name: "DataBot", open_id: "ou_data" } }, }); - const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); + const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret expect(result).toEqual({ ok: true, appId: "cli_123", diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index b7a1292a4f9..744532320de 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -106,6 +106,28 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + function setupNonStreamingAutoDispatcher() { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "auto", + streaming: false, + }, + }); + + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: vi.fn() } as never, + chatId: "oc_chat", + }); + + return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + } + it("skips typing indicator when account typingIndicator is disabled", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -312,25 +334,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); it("suppresses duplicate final text while still sending media", async () => { - resolveFeishuAccountMock.mockReturnValue({ - accountId: "main", - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - config: { - renderMode: "auto", - streaming: false, - }, - }); - - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "plain final" }, { kind: "final" }); await options.deliver( { text: "plain final", mediaUrl: "https://example.com/a.png" }, @@ -352,25 +356,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); it("keeps distinct non-streaming final payloads", async () => { - resolveFeishuAccountMock.mockReturnValue({ - accountId: "main", - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - config: { - renderMode: "auto", - streaming: false, - }, - }); - - createFeishuReplyDispatcher({ - cfg: {} as never, - agentId: "agent", - runtime: { log: vi.fn(), error: vi.fn() } as never, - chatId: "oc_chat", - }); - - const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "notice header" }, { kind: "final" }); await options.deliver({ text: "actual answer body" }, { kind: "final" }); diff --git a/extensions/feishu/src/secret-input.ts b/extensions/feishu/src/secret-input.ts index a2c2f517f3a..37dda74f2eb 100644 --- a/extensions/feishu/src/secret-input.ts +++ b/extensions/feishu/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/feishu"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/feishu/src/tool-account-routing.test.ts b/extensions/feishu/src/tool-account-routing.test.ts index 0631067a07b..b5697676493 100644 --- a/extensions/feishu/src/tool-account-routing.test.ts +++ b/extensions/feishu/src/tool-account-routing.test.ts @@ -35,12 +35,12 @@ function createConfig(params: { accounts: { a: { appId: "app-a", - appSecret: "sec-a", + appSecret: "sec-a", // pragma: allowlist secret tools: params.toolsA, }, b: { appId: "app-b", - appSecret: "sec-b", + appSecret: "sec-b", // pragma: allowlist secret tools: params.toolsB, }, }, diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 0ec4b6185e9..1471f804771 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -308,7 +308,7 @@ describe("loginGeminiCliOAuth", () => { beforeEach(() => { envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; - process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; + process.env.OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET = "GOCSPX-test-client-secret"; // pragma: allowlist secret delete process.env.GEMINI_CLI_OAUTH_CLIENT_ID; delete process.env.GEMINI_CLI_OAUTH_CLIENT_SECRET; delete process.env.GOOGLE_CLOUD_PROJECT; diff --git a/extensions/googlechat/src/api.test.ts b/extensions/googlechat/src/api.test.ts index a8a6b763a4a..fc011268ec2 100644 --- a/extensions/googlechat/src/api.test.ts +++ b/extensions/googlechat/src/api.test.ts @@ -81,7 +81,7 @@ describe("sendGoogleChatMessage", () => { }); const [url, init] = fetchMock.mock.calls[0] ?? []; - expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); + expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); // pragma: allowlist secret expect(JSON.parse(String(init?.body))).toMatchObject({ text: "hello", thread: { name: "spaces/AAA/threads/xyz" }, diff --git a/extensions/googlechat/src/channel.outbound.test.ts b/extensions/googlechat/src/channel.outbound.test.ts index a530d3afe4d..c9180dd8158 100644 --- a/extensions/googlechat/src/channel.outbound.test.ts +++ b/extensions/googlechat/src/channel.outbound.test.ts @@ -12,26 +12,51 @@ vi.mock("./api.js", () => ({ import { googlechatPlugin } from "./channel.js"; import { setGoogleChatRuntime } from "./runtime.js"; +function createGoogleChatCfg(): OpenClawConfig { + return { + channels: { + googlechat: { + enabled: true, + serviceAccount: { + type: "service_account", + client_email: "bot@example.com", + private_key: "test-key", // pragma: allowlist secret + token_uri: "https://oauth2.googleapis.com/token", + }, + }, + }, + }; +} + +function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) { + const loadWebMedia = vi.fn(async () => ({ + buffer: Buffer.from(params.loadBytes), + fileName: params.loadFileName, + contentType: "image/png", + })); + const fetchRemoteMedia = vi.fn(async () => ({ + buffer: Buffer.from("remote-bytes"), + fileName: "remote.png", + contentType: "image/png", + })); + + setGoogleChatRuntime({ + media: { loadWebMedia }, + channel: { + media: { fetchRemoteMedia }, + text: { chunkMarkdownText: (text: string) => [text] }, + }, + } as unknown as PluginRuntime); + + return { loadWebMedia, fetchRemoteMedia }; +} + describe("googlechatPlugin outbound sendMedia", () => { it("loads local media with mediaLocalRoots via runtime media loader", async () => { - const loadWebMedia = vi.fn(async () => ({ - buffer: Buffer.from("image-bytes"), - fileName: "image.png", - contentType: "image/png", - })); - const fetchRemoteMedia = vi.fn(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); - - setGoogleChatRuntime({ - media: { loadWebMedia }, - channel: { - media: { fetchRemoteMedia }, - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); + const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({ + loadFileName: "image.png", + loadBytes: "image-bytes", + }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-1", @@ -40,19 +65,7 @@ describe("googlechatPlugin outbound sendMedia", () => { messageName: "spaces/AAA/messages/msg-1", }); - const cfg: OpenClawConfig = { - channels: { - googlechat: { - enabled: true, - serviceAccount: { - type: "service_account", - client_email: "bot@example.com", - private_key: "test-key", - token_uri: "https://oauth2.googleapis.com/token", - }, - }, - }, - }; + const cfg = createGoogleChatCfg(); const result = await googlechatPlugin.outbound?.sendMedia?.({ cfg, @@ -91,24 +104,10 @@ describe("googlechatPlugin outbound sendMedia", () => { }); it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => { - const loadWebMedia = vi.fn(async () => ({ - buffer: Buffer.from("should-not-be-used"), - fileName: "unused.png", - contentType: "image/png", - })); - const fetchRemoteMedia = vi.fn(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); - - setGoogleChatRuntime({ - media: { loadWebMedia }, - channel: { - media: { fetchRemoteMedia }, - text: { chunkMarkdownText: (text: string) => [text] }, - }, - } as unknown as PluginRuntime); + const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({ + loadFileName: "unused.png", + loadBytes: "should-not-be-used", + }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-2", @@ -117,19 +116,7 @@ describe("googlechatPlugin outbound sendMedia", () => { messageName: "spaces/AAA/messages/msg-2", }); - const cfg: OpenClawConfig = { - channels: { - googlechat: { - enabled: true, - serviceAccount: { - type: "service_account", - client_email: "bot@example.com", - private_key: "test-key", - token_uri: "https://oauth2.googleapis.com/token", - }, - }, - }, - }; + const cfg = createGoogleChatCfg(); const result = await googlechatPlugin.outbound?.sendMedia?.({ cfg, diff --git a/extensions/matrix/src/secret-input.ts b/extensions/matrix/src/secret-input.ts index a5de1214773..c0827573480 100644 --- a/extensions/matrix/src/secret-input.ts +++ b/extensions/matrix/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/matrix"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index 7f823d8fbde..a6379a52664 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -496,7 +496,13 @@ describe("createMattermostInteractionHandler", () => { return res as unknown as ServerResponse & { headers: Record; body: string }; } - it("accepts callback requests from an allowlisted source IP", async () => { + async function runApproveInteraction(params?: { + actionName?: string; + allowedSourceIps?: string[]; + trustedProxies?: string[]; + remoteAddress?: string; + headers?: Record; + }) { const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; const token = generateInteractionToken(context, "acct"); const requestLog: Array<{ path: string; method?: string }> = []; @@ -511,18 +517,22 @@ describe("createMattermostInteractionHandler", () => { channel_id: "chan-1", message: "Choose", props: { - attachments: [{ actions: [{ id: "approve", name: "Approve" }] }], + attachments: [ + { actions: [{ id: "approve", name: params?.actionName ?? "Approve" }] }, + ], }, }; }, } as unknown as MattermostClient, botUserId: "bot", accountId: "acct", - allowedSourceIps: ["198.51.100.8"], + allowedSourceIps: params?.allowedSourceIps, + trustedProxies: params?.trustedProxies, }); const req = createReq({ - remoteAddress: "198.51.100.8", + remoteAddress: params?.remoteAddress, + headers: params?.headers, body: { user_id: "user-1", user_name: "alice", @@ -532,8 +542,45 @@ describe("createMattermostInteractionHandler", () => { }, }); const res = createRes(); - await handler(req, res); + return { res, requestLog }; + } + + async function runInvalidActionRequest(actionId: string) { + const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; + const token = generateInteractionToken(context, "acct"); + const handler = createMattermostInteractionHandler({ + client: { + request: async () => ({ + channel_id: "chan-1", + message: "Choose", + props: { + attachments: [{ actions: [{ id: actionId, name: actionId }] }], + }, + }), + } as unknown as MattermostClient, + botUserId: "bot", + accountId: "acct", + }); + + const req = createReq({ + body: { + user_id: "user-1", + channel_id: "chan-1", + post_id: "post-1", + context: { ...context, _token: token }, + }, + }); + const res = createRes(); + await handler(req, res); + return res; + } + + it("accepts callback requests from an allowlisted source IP", async () => { + const { res, requestLog } = await runApproveInteraction({ + allowedSourceIps: ["198.51.100.8"], + remoteAddress: "198.51.100.8", + }); expect(res.statusCode).toBe(200); expect(res.body).toBe("{}"); @@ -544,43 +591,12 @@ describe("createMattermostInteractionHandler", () => { }); it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => { - const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; - const token = generateInteractionToken(context, "acct"); - const handler = createMattermostInteractionHandler({ - client: { - request: async (_path: string, init?: { method?: string }) => { - if (init?.method === "PUT") { - return { id: "post-1" }; - } - return { - channel_id: "chan-1", - message: "Choose", - props: { - attachments: [{ actions: [{ id: "approve", name: "Approve" }] }], - }, - }; - }, - } as unknown as MattermostClient, - botUserId: "bot", - accountId: "acct", + const { res } = await runApproveInteraction({ allowedSourceIps: ["198.51.100.8"], trustedProxies: ["127.0.0.1"], - }); - - const req = createReq({ remoteAddress: "127.0.0.1", headers: { "x-forwarded-for": "198.51.100.8" }, - body: { - user_id: "user-1", - user_name: "alice", - channel_id: "chan-1", - post_id: "post-1", - context: { ...context, _token: token }, - }, }); - const res = createRes(); - - await handler(req, res); expect(res.statusCode).toBe(200); expect(res.body).toBe("{}"); @@ -703,75 +719,17 @@ describe("createMattermostInteractionHandler", () => { }); it("rejects requests when the action is not present on the fetched post", async () => { - const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; - const token = generateInteractionToken(context, "acct"); - const handler = createMattermostInteractionHandler({ - client: { - request: async () => ({ - channel_id: "chan-1", - message: "Choose", - props: { - attachments: [{ actions: [{ id: "reject", name: "Reject" }] }], - }, - }), - } as unknown as MattermostClient, - botUserId: "bot", - accountId: "acct", - }); - - const req = createReq({ - body: { - user_id: "user-1", - channel_id: "chan-1", - post_id: "post-1", - context: { ...context, _token: token }, - }, - }); - const res = createRes(); - - await handler(req, res); + const res = await runInvalidActionRequest("reject"); expect(res.statusCode).toBe(403); expect(res.body).toContain("Unknown action"); }); it("accepts actions when the button name matches the action id", async () => { - const context = { action_id: "approve", __openclaw_channel_id: "chan-1" }; - const token = generateInteractionToken(context, "acct"); - const requestLog: Array<{ path: string; method?: string }> = []; - const handler = createMattermostInteractionHandler({ - client: { - request: async (path: string, init?: { method?: string }) => { - requestLog.push({ path, method: init?.method }); - if (init?.method === "PUT") { - return { id: "post-1" }; - } - return { - channel_id: "chan-1", - message: "Choose", - props: { - attachments: [{ actions: [{ id: "approve", name: "approve" }] }], - }, - }; - }, - } as unknown as MattermostClient, - botUserId: "bot", - accountId: "acct", + const { res, requestLog } = await runApproveInteraction({ + actionName: "approve", }); - const req = createReq({ - body: { - user_id: "user-1", - user_name: "alice", - channel_id: "chan-1", - post_id: "post-1", - context: { ...context, _token: token }, - }, - }); - const res = createRes(); - - await handler(req, res); - expect(res.statusCode).toBe(200); expect(res.body).toBe("{}"); expect(requestLog).toEqual([ diff --git a/extensions/mattermost/src/normalize.test.ts b/extensions/mattermost/src/normalize.test.ts index 11d8acb2f73..fb7866b34be 100644 --- a/extensions/mattermost/src/normalize.test.ts +++ b/extensions/mattermost/src/normalize.test.ts @@ -74,12 +74,12 @@ describe("looksLikeMattermostTargetId", () => { it("recognizes 26-char alphanumeric Mattermost IDs", () => { expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true); expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true); - expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); + expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); // pragma: allowlist secret }); it("recognizes DM channel format (26__26)", () => { expect( - looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), + looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), // pragma: allowlist secret ).toBe(true); }); @@ -91,6 +91,6 @@ describe("looksLikeMattermostTargetId", () => { }); it("rejects strings longer than 26 chars that are not DM format", () => { - expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); + expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); // pragma: allowlist secret }); }); diff --git a/extensions/mattermost/src/secret-input.ts b/extensions/mattermost/src/secret-input.ts index 017109424bc..576f5b9fc45 100644 --- a/extensions/mattermost/src/secret-input.ts +++ b/extensions/mattermost/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/mattermost"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index eb323d9a353..a71beb76226 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -140,7 +140,7 @@ function createConfig(port: number): OpenClawConfig { msteams: { enabled: true, appId: "app-id", - appPassword: "app-password", + appPassword: "app-password", // pragma: allowlist secret tenantId: "tenant-id", webhook: { port, diff --git a/extensions/msteams/src/token.test.ts b/extensions/msteams/src/token.test.ts index fde4a61f8e3..732b561a2b0 100644 --- a/extensions/msteams/src/token.test.ts +++ b/extensions/msteams/src/token.test.ts @@ -35,7 +35,7 @@ describe("resolveMSTeamsCredentials", () => { expect(resolved).toEqual({ appId: "app-id", - appPassword: "app-password", + appPassword: "app-password", // pragma: allowlist secret tenantId: "tenant-id", }); }); diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts index 7d806ee51b2..79b3cd77cd5 100644 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -21,11 +21,11 @@ function buildAccount(): ResolvedNextcloudTalkAccount { accountId: "default", enabled: true, baseUrl: "https://nextcloud.example.com", - secret: "secret", - secretSource: "config", + secret: "secret", // pragma: allowlist secret + secretSource: "config", // pragma: allowlist secret config: { baseUrl: "https://nextcloud.example.com", - botSecret: "secret", + botSecret: "secret", // pragma: allowlist secret webhookPath: "/nextcloud-talk-webhook", webhookPort: 8788, }, diff --git a/extensions/nextcloud-talk/src/secret-input.ts b/extensions/nextcloud-talk/src/secret-input.ts index f51a0ad6872..d26cb8e4e23 100644 --- a/extensions/nextcloud-talk/src/secret-input.ts +++ b/extensions/nextcloud-talk/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/nextcloud-talk"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts index 3933b13de5a..88133f9cbed 100644 --- a/extensions/nextcloud-talk/src/send.test.ts +++ b/extensions/nextcloud-talk/src/send.test.ts @@ -8,7 +8,7 @@ const hoisted = vi.hoisted(() => ({ resolveNextcloudTalkAccount: vi.fn(() => ({ accountId: "default", baseUrl: "https://nextcloud.example.com", - secret: "secret-value", + secret: "secret-value", // pragma: allowlist secret })), generateNextcloudTalkSignature: vi.fn(() => ({ random: "r", diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 96f2f29b46b..0aa63485951 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -51,8 +51,8 @@ describe("nostr outbound cfg threading", () => { accountId: "default", enabled: true, configured: true, - privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // pragma: allowlist secret + publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", // pragma: allowlist secret relays: ["wss://relay.example.com"], config: {}, }, @@ -63,7 +63,7 @@ describe("nostr outbound cfg threading", () => { const cfg = { channels: { nostr: { - privateKey: "resolved-nostr-private-key", + privateKey: "resolved-nostr-private-key", // pragma: allowlist secret }, }, }; diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 7d5968a961d..8fb17c443f4 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -283,6 +283,36 @@ describe("nostr-profile-http", () => { expect(res._getStatusCode()).toBe(403); }); + it("rejects profile mutation with cross-site sec-fetch-site header", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { "sec-fetch-site": "cross-site" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + + it("rejects profile mutation when forwarded client ip is non-loopback", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("rejects private IP in picture URL (SSRF protection)", async () => { await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); @@ -431,6 +461,21 @@ describe("nostr-profile-http", () => { expect(res._getStatusCode()).toBe(403); }); + it("rejects import mutation when x-real-ip is non-loopback", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "POST", + "/api/channels/nostr/default/profile/import", + {}, + { headers: { "x-real-ip": "198.51.100.55" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("auto-merges when requested", async () => { const ctx = createMockContext({ getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }), diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index b4d53e16a4e..3dedf745125 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -224,6 +224,51 @@ function isLoopbackOriginLike(value: string): boolean { } } +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]; + } + return typeof value === "string" ? value : undefined; +} + +function normalizeIpCandidate(raw: string): string { + const unquoted = raw.trim().replace(/^"|"$/g, ""); + const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/); + if (bracketedWithOptionalPort) { + return bracketedWithOptionalPort[1] ?? ""; + } + const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/); + if (ipv4WithPort) { + return ipv4WithPort[1] ?? ""; + } + return unquoted; +} + +function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean { + const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]); + if (forwardedFor) { + for (const hop of forwardedFor.split(",")) { + const candidate = normalizeIpCandidate(hop); + if (!candidate) { + continue; + } + if (!isLoopbackRemoteAddress(candidate)) { + return true; + } + } + } + + const realIp = firstHeaderValue(req.headers["x-real-ip"]); + if (realIp) { + const candidate = normalizeIpCandidate(realIp); + if (candidate && !isLoopbackRemoteAddress(candidate)) { + return true; + } + } + + return false; +} + function enforceLoopbackMutationGuards( ctx: NostrProfileHttpContext, req: IncomingMessage, @@ -237,15 +282,30 @@ function enforceLoopbackMutationGuards( return false; } + // If a proxy exposes client-origin headers showing a non-loopback client, + // treat this as a remote request and deny mutation. + if (hasNonLoopbackForwardedClient(req)) { + ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers"); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + + const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase(); + if (secFetchSite === "cross-site") { + ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header"); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + // CSRF guard: browsers send Origin/Referer on cross-site requests. - const origin = req.headers.origin; + const origin = firstHeaderValue(req.headers.origin); if (typeof origin === "string" && !isLoopbackOriginLike(origin)) { ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`); sendJson(res, 403, { ok: false, error: "Forbidden" }); return false; } - const referer = req.headers.referer ?? req.headers.referrer; + const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer); if (typeof referer === "string" && !isLoopbackOriginLike(referer)) { ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`); sendJson(res, 403, { ok: false, error: "Forbidden" }); diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 2d4efa3f956..ad6860d6f8d 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -144,7 +144,7 @@ describe("slackPlugin config", () => { slack: { mode: "http", botToken: "xoxb-http", - signingSecret: "secret-http", + signingSecret: "secret-http", // pragma: allowlist secret }, }, }; @@ -214,9 +214,9 @@ describe("slackPlugin config", () => { configured: true, mode: "http", botTokenStatus: "available", - signingSecretStatus: "configured_unavailable", + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret botTokenSource: "config", - signingSecretSource: "config", + signingSecretSource: "config", // pragma: allowlist secret config: { mode: "http", botToken: "xoxb-http", diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 81ef191ba77..d84516dbda5 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -282,7 +282,7 @@ export function createSynologyChatPlugin() { Surface: CHANNEL_ID, ConversationLabel: msg.senderName || msg.from, Timestamp: Date.now(), - CommandAuthorized: true, + CommandAuthorized: msg.commandAuthorized, }); // Dispatch via the SDK's buffered block dispatcher diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 2f6bd87788a..37ee566e6a6 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -237,6 +237,7 @@ describe("createWebhookHandler", () => { body: "Hello from json", from: "123", senderName: "json-user", + commandAuthorized: true, }), ); }); @@ -396,6 +397,7 @@ describe("createWebhookHandler", () => { senderName: "testuser", provider: "synology-chat", chatType: "direct", + commandAuthorized: true, }), ); }); @@ -422,6 +424,7 @@ describe("createWebhookHandler", () => { expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ body: expect.stringContaining("[FILTERED]"), + commandAuthorized: true, }), ); }); diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index fab4b9a0238..b4c73934db9 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -225,6 +225,7 @@ export interface WebhookHandlerDeps { chatType: string; sessionKey: string; accountId: string; + commandAuthorized: boolean; /** Chat API user_id for sending replies (may differ from webhook user_id) */ chatUserId?: string; }) => Promise; @@ -364,6 +365,7 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) { chatType: "direct", sessionKey, accountId: account.accountId, + commandAuthorized: auth.allowed, chatUserId: replyUserId, }); diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 7473bb5e533..1f40a5f1cce 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -129,7 +129,7 @@ describe("telegramPlugin duplicate token guard", () => { cfg.channels!.telegram!.accounts!.ops = { ...cfg.channels!.telegram!.accounts!.ops, webhookUrl: "https://example.test/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: 9876, }; diff --git a/extensions/zalo/src/secret-input.ts b/extensions/zalo/src/secret-input.ts index 702548454c3..bf218d1e48b 100644 --- a/extensions/zalo/src/secret-input.ts +++ b/extensions/zalo/src/secret-input.ts @@ -1,19 +1,13 @@ import { + buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/zalo"; -import { z } from "zod"; -export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString }; - -export function buildSecretInputSchema() { - return z.union([ - z.string(), - z.object({ - source: z.enum(["env", "file", "exec"]), - provider: z.string().min(1), - id: z.string().min(1), - }), - ]); -} +export { + buildSecretInputSchema, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +}; diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 728edff704a..195f3dfe1a6 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -1,5 +1,3 @@ -import fsp from "node:fs/promises"; -import path from "node:path"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, @@ -14,7 +12,6 @@ import { normalizeAccountId, promptAccountId, promptChannelAccessConfig, - resolvePreferredOpenClawTmpDir, } from "openclaw/plugin-sdk/zalouser"; import { listZalouserAccountIds, @@ -22,6 +19,7 @@ import { resolveZalouserAccountSync, checkZcaAuthenticated, } from "./accounts.js"; +import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { logoutZaloProfile, resolveZaloAllowFromEntries, @@ -103,25 +101,6 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { ); } -async function writeQrDataUrlToTempFile( - qrDataUrl: string, - profile: string, -): Promise { - const trimmed = qrDataUrl.trim(); - const match = trimmed.match(/^data:image\/png;base64,(.+)$/i); - const base64 = (match?.[1] ?? "").trim(); - if (!base64) { - return null; - } - const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default"; - const filePath = path.join( - resolvePreferredOpenClawTmpDir(), - `openclaw-zalouser-qr-${safeProfile}.png`, - ); - await fsp.writeFile(filePath, Buffer.from(base64, "base64")); - return filePath; -} - async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; diff --git a/openclaw.mjs b/openclaw.mjs index 60aada1bd64..248db52ea44 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -26,9 +26,9 @@ const ensureSupportedNodeVersion = () => { process.stderr.write( `openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` + "If you use nvm, run:\n" + - " nvm install 22\n" + - " nvm use 22\n" + - " nvm alias default 22\n", + ` nvm install ${MIN_NODE_MAJOR}\n` + + ` nvm use ${MIN_NODE_MAJOR}\n` + + ` nvm alias default ${MIN_NODE_MAJOR}\n`, ); process.exit(1); }; diff --git a/src/acp/client.test.ts b/src/acp/client.test.ts index bb5340115a1..cbb52bd73cc 100644 --- a/src/acp/client.test.ts +++ b/src/acp/client.test.ts @@ -10,6 +10,8 @@ import { } from "./client.js"; import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js"; +const envVar = (...parts: string[]) => parts.join("_"); + function makePermissionRequest( overrides: Partial = {}, ): RequestPermissionRequest { @@ -62,42 +64,47 @@ describe("resolveAcpClientSpawnEnv", () => { }); it("strips skill-injected env keys when stripKeys is provided", () => { - const stripKeys = new Set(["OPENAI_API_KEY", "ELEVENLABS_API_KEY"]); + const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY"); + const elevenLabsApiKeyEnv = envVar("ELEVENLABS", "API", "KEY"); + const anthropicApiKeyEnv = envVar("ANTHROPIC", "API", "KEY"); + const stripKeys = new Set([openAiApiKeyEnv, elevenLabsApiKeyEnv]); const env = resolveAcpClientSpawnEnv( { PATH: "/usr/bin", - OPENAI_API_KEY: "sk-leaked-from-skill", - ELEVENLABS_API_KEY: "el-leaked", - ANTHROPIC_API_KEY: "sk-keep-this", + [openAiApiKeyEnv]: "openai-test-value", // pragma: allowlist secret + [elevenLabsApiKeyEnv]: "elevenlabs-test-value", // pragma: allowlist secret + [anthropicApiKeyEnv]: "anthropic-test-value", // pragma: allowlist secret }, { stripKeys }, ); expect(env.PATH).toBe("/usr/bin"); expect(env.OPENCLAW_SHELL).toBe("acp-client"); - expect(env.ANTHROPIC_API_KEY).toBe("sk-keep-this"); + expect(env.ANTHROPIC_API_KEY).toBe("anthropic-test-value"); expect(env.OPENAI_API_KEY).toBeUndefined(); expect(env.ELEVENLABS_API_KEY).toBeUndefined(); }); it("does not modify the original baseEnv when stripping keys", () => { + const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY"); const baseEnv: NodeJS.ProcessEnv = { - OPENAI_API_KEY: "sk-original", + [openAiApiKeyEnv]: "openai-original", // pragma: allowlist secret PATH: "/usr/bin", }; - const stripKeys = new Set(["OPENAI_API_KEY"]); + const stripKeys = new Set([openAiApiKeyEnv]); resolveAcpClientSpawnEnv(baseEnv, { stripKeys }); - expect(baseEnv.OPENAI_API_KEY).toBe("sk-original"); + expect(baseEnv.OPENAI_API_KEY).toBe("openai-original"); }); it("preserves OPENCLAW_SHELL even when stripKeys contains it", () => { + const openAiApiKeyEnv = envVar("OPENAI", "API", "KEY"); const env = resolveAcpClientSpawnEnv( { OPENCLAW_SHELL: "skill-overridden", - OPENAI_API_KEY: "sk-leaked", + [openAiApiKeyEnv]: "openai-leaked", // pragma: allowlist secret }, - { stripKeys: new Set(["OPENCLAW_SHELL", "OPENAI_API_KEY"]) }, + { stripKeys: new Set(["OPENCLAW_SHELL", openAiApiKeyEnv]) }, ); expect(env.OPENCLAW_SHELL).toBe("acp-client"); diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index bcc9717b167..0c19d487ab7 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -180,7 +180,7 @@ describe("serveAcpGateway startup", () => { it("passes resolved SecretInput gateway credentials to the ACP gateway client", async () => { mockState.resolveGatewayCredentialsWithSecretInputs.mockResolvedValue({ token: undefined, - password: "resolved-secret-password", + password: "resolved-secret-password", // pragma: allowlist secret }); const { signalHandlers, onceSpy } = captureProcessSignalHandlers(); @@ -195,7 +195,7 @@ describe("serveAcpGateway startup", () => { ); expect(mockState.gatewayAuth[0]).toEqual({ token: undefined, - password: "resolved-secret-password", + password: "resolved-secret-password", // pragma: allowlist secret }); const gateway = getMockGateway(); diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 4fad1029035..9d47be8c79e 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -23,8 +23,8 @@ vi.mock("@mariozechner/pi-ai", async () => { ...actual, getOAuthApiKey: getOAuthApiKeyMock, getOAuthProviders: () => [ - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, + { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret + { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret ], }; }); @@ -91,7 +91,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }); expect(result).toEqual({ - apiKey: "cached-access-token", + apiKey: "cached-access-token", // pragma: allowlist secret provider: "openai-codex", email: undefined, }); diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index 05ccdb5af04..c38d043c549 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -45,6 +45,20 @@ async function resolveWithConfig(params: { }); } +async function withEnvVar(key: string, value: string, run: () => Promise): Promise { + const previous = process.env[key]; + process.env[key] = value; + try { + return await run(); + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } +} + describe("resolveApiKeyForProfile config compatibility", () => { it("accepts token credentials when config mode is oauth", async () => { const profileId = "anthropic:token"; @@ -263,9 +277,7 @@ describe("resolveApiKeyForProfile secret refs", () => { it("resolves token tokenRef from env", async () => { const profileId = "github-copilot:default"; - const previous = process.env.GITHUB_TOKEN; - process.env.GITHUB_TOKEN = "gh-ref-token"; - try { + await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => { const result = await resolveApiKeyForProfile({ cfg: cfgFor(profileId, "github-copilot", "token"), store: { @@ -286,20 +298,12 @@ describe("resolveApiKeyForProfile secret refs", () => { provider: "github-copilot", email: undefined, }); - } finally { - if (previous === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previous; - } - } + }); }); it("resolves token tokenRef without inline token when expires is absent", async () => { const profileId = "github-copilot:no-inline-token"; - const previous = process.env.GITHUB_TOKEN; - process.env.GITHUB_TOKEN = "gh-ref-token"; - try { + await withEnvVar("GITHUB_TOKEN", "gh-ref-token", async () => { const result = await resolveApiKeyForProfile({ cfg: cfgFor(profileId, "github-copilot", "token"), store: { @@ -319,13 +323,7 @@ describe("resolveApiKeyForProfile secret refs", () => { provider: "github-copilot", email: undefined, }); - } finally { - if (previous === undefined) { - delete process.env.GITHUB_TOKEN; - } else { - process.env.GITHUB_TOKEN = previous; - } - } + }); }); it("resolves inline ${ENV} api_key values", async () => { diff --git a/src/agents/compaction.tool-result-details.test.ts b/src/agents/compaction.tool-result-details.test.ts index 581e596ccbe..48e16c073a9 100644 --- a/src/agents/compaction.tool-result-details.test.ts +++ b/src/agents/compaction.tool-result-details.test.ts @@ -54,7 +54,7 @@ describe("compaction toolResult details stripping", () => { messages, // Minimal shape; compaction won't use these fields in our mocked generateSummary. model: { id: "mock", name: "mock", contextWindow: 10000, maxTokens: 1000 } as never, - apiKey: "test", + apiKey: "test", // pragma: allowlist secret signal: new AbortController().signal, reserveTokens: 100, maxChunkTokens: 5000, diff --git a/src/agents/kilocode-models.test.ts b/src/agents/kilocode-models.test.ts index d13ee888439..f092baa7ca4 100644 --- a/src/agents/kilocode-models.test.ts +++ b/src/agents/kilocode-models.test.ts @@ -54,6 +54,34 @@ function makeAutoModel(overrides: Record = {}) { }); } +async function withFetchPathTest( + mockFetch: ReturnType, + runAssertions: () => Promise, +) { + const origNodeEnv = process.env.NODE_ENV; + const origVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + + vi.stubGlobal("fetch", mockFetch); + + try { + await runAssertions(); + } finally { + if (origNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = origNodeEnv; + } + if (origVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = origVitest; + } + vi.unstubAllGlobals(); + } +} + describe("discoverKilocodeModels", () => { it("returns static catalog in test environment", async () => { // Default vitest env — should return static catalog without fetching @@ -77,12 +105,6 @@ describe("discoverKilocodeModels", () => { describe("discoverKilocodeModels (fetch path)", () => { it("parses gateway models with correct pricing conversion", async () => { - // Temporarily unset test env flags to exercise the fetch path - const origNodeEnv = process.env.NODE_ENV; - const origVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => @@ -90,9 +112,7 @@ describe("discoverKilocodeModels (fetch path)", () => { data: [makeAutoModel(), makeGatewayModel()], }), }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); // Should have fetched from the gateway URL @@ -123,68 +143,31 @@ describe("discoverKilocodeModels (fetch path)", () => { // Verify context/tokens expect(sonnet?.contextWindow).toBe(200000); expect(sonnet?.maxTokens).toBe(8192); - } finally { - process.env.NODE_ENV = origNodeEnv; - if (origVitest !== undefined) { - process.env.VITEST = origVitest; - } - vi.unstubAllGlobals(); - } + }); }); it("falls back to static catalog on network error", async () => { - const origNodeEnv = process.env.NODE_ENV; - const origVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockRejectedValue(new Error("network error")); - vi.stubGlobal("fetch", mockFetch); - - try { + await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); - } finally { - process.env.NODE_ENV = origNodeEnv; - if (origVitest !== undefined) { - process.env.VITEST = origVitest; - } - vi.unstubAllGlobals(); - } + }); }); it("falls back to static catalog on HTTP error", async () => { - const origNodeEnv = process.env.NODE_ENV; - const origVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500, }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.length).toBeGreaterThan(0); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); - } finally { - process.env.NODE_ENV = origNodeEnv; - if (origVitest !== undefined) { - process.env.VITEST = origVitest; - } - vi.unstubAllGlobals(); - } + }); }); it("ensures kilo/auto is present even when API doesn't return it", async () => { - const origNodeEnv = process.env.NODE_ENV; - const origVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => @@ -192,27 +175,14 @@ describe("discoverKilocodeModels (fetch path)", () => { data: [makeGatewayModel()], // no kilo/auto }), }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); expect(models.some((m) => m.id === "kilo/auto")).toBe(true); expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); - } finally { - process.env.NODE_ENV = origNodeEnv; - if (origVitest !== undefined) { - process.env.VITEST = origVitest; - } - vi.unstubAllGlobals(); - } + }); }); it("detects text-only models without image modality", async () => { - const origNodeEnv = process.env.NODE_ENV; - const origVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const textOnlyModel = makeGatewayModel({ id: "some/text-model", architecture: { @@ -226,28 +196,15 @@ describe("discoverKilocodeModels (fetch path)", () => { ok: true, json: () => Promise.resolve({ data: [textOnlyModel] }), }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); const textModel = models.find((m) => m.id === "some/text-model"); expect(textModel?.input).toEqual(["text"]); expect(textModel?.reasoning).toBe(false); - } finally { - process.env.NODE_ENV = origNodeEnv; - if (origVitest !== undefined) { - process.env.VITEST = origVitest; - } - vi.unstubAllGlobals(); - } + }); }); it("keeps a later valid duplicate when an earlier entry is malformed", async () => { - const origNodeEnv = process.env.NODE_ENV; - const origVitest = process.env.VITEST; - delete process.env.NODE_ENV; - delete process.env.VITEST; - const malformedAutoModel = makeAutoModel({ name: "Broken Kilo Auto", pricing: undefined, @@ -260,21 +217,13 @@ describe("discoverKilocodeModels (fetch path)", () => { data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()], }), }); - vi.stubGlobal("fetch", mockFetch); - - try { + await withFetchPathTest(mockFetch, async () => { const models = await discoverKilocodeModels(); const auto = models.find((m) => m.id === "kilo/auto"); expect(auto).toBeDefined(); expect(auto?.name).toBe("Kilo: Auto"); expect(auto?.cost.input).toBeCloseTo(5.0); expect(models.some((m) => m.id === "anthropic/claude-sonnet-4")).toBe(true); - } finally { - process.env.NODE_ENV = origNodeEnv; - if (origVitest !== undefined) { - process.env.VITEST = origVitest; - } - vi.unstubAllGlobals(); - } + }); }); }); diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 6fab1dd3946..9372b4c7696 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -188,7 +188,7 @@ describe("memory search config", () => { provider: "openai", remote: { baseUrl: "https://default.example/v1", - apiKey: "default-key", + apiKey: "default-key", // pragma: allowlist secret headers: { "X-Default": "on" }, }, }, @@ -209,7 +209,7 @@ describe("memory search config", () => { const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.remote).toEqual({ baseUrl: "https://agent.example/v1", - apiKey: "default-key", + apiKey: "default-key", // pragma: allowlist secret headers: { "X-Default": "on" }, batch: { enabled: false, @@ -228,7 +228,7 @@ describe("memory search config", () => { memorySearch: { provider: "openai", remote: { - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret headers: { "X-Default": "on" }, }, }, diff --git a/src/agents/minimax-vlm.normalizes-api-key.test.ts b/src/agents/minimax-vlm.normalizes-api-key.test.ts index effebb88816..faa33b8682c 100644 --- a/src/agents/minimax-vlm.normalizes-api-key.test.ts +++ b/src/agents/minimax-vlm.normalizes-api-key.test.ts @@ -3,30 +3,31 @@ import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; describe("minimaxUnderstandImage apiKey normalization", () => { const priorFetch = global.fetch; + const apiResponse = JSON.stringify({ + base_resp: { status_code: 0, status_msg: "ok" }, + content: "ok", + }); afterEach(() => { global.fetch = priorFetch; vi.restoreAllMocks(); }); - it("strips embedded CR/LF before sending Authorization header", async () => { + async function runNormalizationCase(apiKey: string) { const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const auth = (init?.headers as Record | undefined)?.Authorization; expect(auth).toBe("Bearer minimax-test-key"); - return new Response( - JSON.stringify({ - base_resp: { status_code: 0, status_msg: "ok" }, - content: "ok", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + return new Response(apiResponse, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); }); global.fetch = withFetchPreconnect(fetchSpy); const { minimaxUnderstandImage } = await import("./minimax-vlm.js"); const text = await minimaxUnderstandImage({ - apiKey: "minimax-test-\r\nkey", + apiKey, prompt: "hi", imageDataUrl: "data:image/png;base64,AAAA", apiHost: "https://api.minimax.io", @@ -34,32 +35,13 @@ describe("minimaxUnderstandImage apiKey normalization", () => { expect(text).toBe("ok"); expect(fetchSpy).toHaveBeenCalled(); + } + + it("strips embedded CR/LF before sending Authorization header", async () => { + await runNormalizationCase("minimax-test-\r\nkey"); }); it("drops non-Latin1 characters from apiKey before sending Authorization header", async () => { - const fetchSpy = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - const auth = (init?.headers as Record | undefined)?.Authorization; - expect(auth).toBe("Bearer minimax-test-key"); - - return new Response( - JSON.stringify({ - base_resp: { status_code: 0, status_msg: "ok" }, - content: "ok", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - global.fetch = withFetchPreconnect(fetchSpy); - - const { minimaxUnderstandImage } = await import("./minimax-vlm.js"); - const text = await minimaxUnderstandImage({ - apiKey: "minimax-\u0417\u2502test-key", - prompt: "hi", - imageDataUrl: "data:image/png;base64,AAAA", - apiHost: "https://api.minimax.io", - }); - - expect(text).toBe("ok"); - expect(fetchSpy).toHaveBeenCalled(); + await runNormalizationCase("minimax-\u0417\u2502test-key"); }); }); diff --git a/src/agents/model-auth-env-vars.ts b/src/agents/model-auth-env-vars.ts new file mode 100644 index 00000000000..c366138207c --- /dev/null +++ b/src/agents/model-auth-env-vars.ts @@ -0,0 +1,42 @@ +export const PROVIDER_ENV_API_KEY_CANDIDATES: Record = { + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + chutes: ["CHUTES_OAUTH_TOKEN", "CHUTES_API_KEY"], + zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], + opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + volcengine: ["VOLCANO_ENGINE_API_KEY"], + "volcengine-plan": ["VOLCANO_ENGINE_API_KEY"], + byteplus: ["BYTEPLUS_API_KEY"], + "byteplus-plan": ["BYTEPLUS_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + openai: ["OPENAI_API_KEY"], + google: ["GEMINI_API_KEY"], + voyage: ["VOYAGE_API_KEY"], + groq: ["GROQ_API_KEY"], + deepgram: ["DEEPGRAM_API_KEY"], + cerebras: ["CEREBRAS_API_KEY"], + xai: ["XAI_API_KEY"], + openrouter: ["OPENROUTER_API_KEY"], + litellm: ["LITELLM_API_KEY"], + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + moonshot: ["MOONSHOT_API_KEY"], + minimax: ["MINIMAX_API_KEY"], + nvidia: ["NVIDIA_API_KEY"], + xiaomi: ["XIAOMI_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + venice: ["VENICE_API_KEY"], + mistral: ["MISTRAL_API_KEY"], + together: ["TOGETHER_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + ollama: ["OLLAMA_API_KEY"], + vllm: ["VLLM_API_KEY"], + kilocode: ["KILOCODE_API_KEY"], +}; + +export function listKnownProviderEnvApiKeyNames(): string[] { + return [...new Set(Object.values(PROVIDER_ENV_API_KEY_CANDIDATES).flat())]; +} diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index 85fa4bc43fb..a46eebbbc34 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -32,7 +32,7 @@ describe("resolveModelAuthLabel", () => { "github-copilot:default": { type: "token", provider: "github-copilot", - token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // pragma: allowlist secret tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, }, }, @@ -52,7 +52,7 @@ describe("resolveModelAuthLabel", () => { }); it("does not include api-key value in label for api-key profiles", () => { - const shortSecret = "abc123"; + const shortSecret = "abc123"; // pragma: allowlist secret ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: { diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts new file mode 100644 index 00000000000..e2225588df7 --- /dev/null +++ b/src/agents/model-auth-markers.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; +import { isNonSecretApiKeyMarker, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; + +describe("model auth markers", () => { + it("recognizes explicit non-secret markers", () => { + expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true); + expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true); + expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); + }); + + it("recognizes known env marker names but not arbitrary all-caps keys", () => { + expect(isNonSecretApiKeyMarker("OPENAI_API_KEY")).toBe(true); + expect(isNonSecretApiKeyMarker("ALLCAPS_EXAMPLE")).toBe(false); + }); + + it("recognizes all built-in provider env marker names", () => { + for (const envVarName of listKnownProviderEnvApiKeyNames()) { + expect(isNonSecretApiKeyMarker(envVarName)).toBe(true); + } + }); + + it("can exclude env marker-name interpretation for display-only paths", () => { + expect(isNonSecretApiKeyMarker("OPENAI_API_KEY", { includeEnvVarName: false })).toBe(false); + }); +}); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts new file mode 100644 index 00000000000..0b3b4960eb8 --- /dev/null +++ b/src/agents/model-auth-markers.ts @@ -0,0 +1,80 @@ +import type { SecretRefSource } from "../config/types.secrets.js"; +import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; + +export const MINIMAX_OAUTH_MARKER = "minimax-oauth"; +export const QWEN_OAUTH_MARKER = "qwen-oauth"; +export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; +export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret +export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret + +const AWS_SDK_ENV_MARKERS = new Set([ + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_PROFILE", +]); + +// Legacy marker names kept for backward compatibility with existing models.json files. +const LEGACY_ENV_API_KEY_MARKERS = [ + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "PERPLEXITY_API_KEY", + "FIREWORKS_API_KEY", + "NOVITA_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_API_KEY", + "MINIMAX_CODE_PLAN_KEY", +]; + +const KNOWN_ENV_API_KEY_MARKERS = new Set([ + ...listKnownProviderEnvApiKeyNames(), + ...LEGACY_ENV_API_KEY_MARKERS, + ...AWS_SDK_ENV_MARKERS, +]); + +export function isAwsSdkAuthMarker(value: string): boolean { + return AWS_SDK_ENV_MARKERS.has(value.trim()); +} + +export function resolveNonEnvSecretRefApiKeyMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveNonEnvSecretRefHeaderValueMarker(_source: SecretRefSource): string { + return NON_ENV_SECRETREF_MARKER; +} + +export function resolveEnvSecretRefHeaderValueMarker(envVarName: string): string { + return `${SECRETREF_ENV_HEADER_MARKER_PREFIX}${envVarName.trim()}`; +} + +export function isSecretRefHeaderValueMarker(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed === NON_ENV_SECRETREF_MARKER || trimmed.startsWith(SECRETREF_ENV_HEADER_MARKER_PREFIX) + ); +} + +export function isNonSecretApiKeyMarker( + value: string, + opts?: { includeEnvVarName?: boolean }, +): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const isKnownMarker = + trimmed === MINIMAX_OAUTH_MARKER || + trimmed === QWEN_OAUTH_MARKER || + trimmed === OLLAMA_LOCAL_AUTH_MARKER || + trimmed === NON_ENV_SECRETREF_MARKER || + isAwsSdkAuthMarker(trimmed); + if (isKnownMarker) { + return true; + } + if (opts?.includeEnvVarName === false) { + return false; + } + // Do not treat arbitrary ALL_CAPS values as markers; only recognize the + // known env-var markers we intentionally persist for compatibility. + return KNOWN_ENV_API_KEY_MARKERS.has(trimmed); +} diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts index e2d9d09ab12..5fabcf2dcc6 100644 --- a/src/agents/model-auth.profiles.test.ts +++ b/src/agents/model-auth.profiles.test.ts @@ -7,6 +7,8 @@ import { withEnvAsync } from "../test-utils/env.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js"; +const envVar = (...parts: string[]) => parts.join("_"); + const oauthFixture = { access: "access-token", refresh: "refresh-token", @@ -191,7 +193,7 @@ describe("getApiKeyForModel", () => { await withEnvAsync( { ZAI_API_KEY: undefined, - Z_AI_API_KEY: "zai-test-key", + Z_AI_API_KEY: "zai-test-key", // pragma: allowlist secret }, async () => { const resolved = await resolveApiKeyForProvider({ @@ -205,7 +207,8 @@ describe("getApiKeyForModel", () => { }); it("resolves Synthetic API key from env", async () => { - await withEnvAsync({ SYNTHETIC_API_KEY: "synthetic-test-key" }, async () => { + await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "synthetic", store: { version: 1, profiles: {} }, @@ -216,7 +219,8 @@ describe("getApiKeyForModel", () => { }); it("resolves Qianfan API key from env", async () => { - await withEnvAsync({ QIANFAN_API_KEY: "qianfan-test-key" }, async () => { + await withEnvAsync({ [envVar("QIANFAN", "API", "KEY")]: "qianfan-test-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "qianfan", store: { version: 1, profiles: {} }, @@ -250,7 +254,8 @@ describe("getApiKeyForModel", () => { }); it("prefers explicit OLLAMA_API_KEY over synthetic local key", async () => { - await withEnvAsync({ OLLAMA_API_KEY: "env-ollama-key" }, async () => { + await withEnvAsync({ [envVar("OLLAMA", "API", "KEY")]: "env-ollama-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "ollama", store: { version: 1, profiles: {} }, @@ -283,7 +288,8 @@ describe("getApiKeyForModel", () => { }); it("resolves Vercel AI Gateway API key from env", async () => { - await withEnvAsync({ AI_GATEWAY_API_KEY: "gateway-test-key" }, async () => { + await withEnvAsync({ [envVar("AI_GATEWAY", "API", "KEY")]: "gateway-test-key" }, async () => { + // pragma: allowlist secret const resolved = await resolveApiKeyForProvider({ provider: "vercel-ai-gateway", store: { version: 1, profiles: {} }, @@ -296,9 +302,9 @@ describe("getApiKeyForModel", () => { it("prefers Bedrock bearer token over access keys and profile", async () => { await expectBedrockAuthSource({ env: { - AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", + AWS_BEARER_TOKEN_BEDROCK: "bedrock-token", // pragma: allowlist secret AWS_ACCESS_KEY_ID: "access-key", - AWS_SECRET_ACCESS_KEY: "secret-key", + [envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret AWS_PROFILE: "profile", }, expectedSource: "AWS_BEARER_TOKEN_BEDROCK", @@ -310,7 +316,7 @@ describe("getApiKeyForModel", () => { env: { AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_ACCESS_KEY_ID: "access-key", - AWS_SECRET_ACCESS_KEY: "secret-key", + [envVar("AWS", "SECRET", "ACCESS", "KEY")]: "secret-key", // pragma: allowlist secret AWS_PROFILE: "profile", }, expectedSource: "AWS_ACCESS_KEY_ID", @@ -330,7 +336,8 @@ describe("getApiKeyForModel", () => { }); it("accepts VOYAGE_API_KEY for voyage", async () => { - await withEnvAsync({ VOYAGE_API_KEY: "voyage-test-key" }, async () => { + await withEnvAsync({ [envVar("VOYAGE", "API", "KEY")]: "voyage-test-key" }, async () => { + // pragma: allowlist secret const voyage = await resolveApiKeyForProvider({ provider: "voyage", store: { version: 1, profiles: {} }, @@ -341,7 +348,8 @@ describe("getApiKeyForModel", () => { }); it("strips embedded CR/LF from ANTHROPIC_API_KEY", async () => { - await withEnvAsync({ ANTHROPIC_API_KEY: "sk-ant-test-\r\nkey" }, async () => { + await withEnvAsync({ [envVar("ANTHROPIC", "API", "KEY")]: "sk-ant-test-\r\nkey" }, async () => { + // pragma: allowlist secret const resolved = resolveEnvApiKey("anthropic"); expect(resolved?.apiKey).toBe("sk-ant-test-key"); expect(resolved?.source).toContain("ANTHROPIC_API_KEY"); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 68a117c96a9..b8b0ac9336b 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -16,6 +16,8 @@ import { resolveAuthProfileOrder, resolveAuthStorePathForDisplay, } from "./auth-profiles.js"; +import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "./model-auth-markers.js"; import { normalizeProviderId } from "./model-selection.js"; export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js"; @@ -90,7 +92,7 @@ function resolveSyntheticLocalProviderAuth(params: { } return { - apiKey: "ollama-local", // pragma: allowlist secret + apiKey: OLLAMA_LOCAL_AUTH_MARKER, source: "models.providers.ollama (synthetic local key)", mode: "api-key", }; @@ -281,20 +283,14 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return { apiKey: value, source }; }; - if (normalized === "github-copilot") { - return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN"); - } - - if (normalized === "anthropic") { - return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY"); - } - - if (normalized === "chutes") { - return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY"); - } - - if (normalized === "zai") { - return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY"); + const candidates = PROVIDER_ENV_API_KEY_CANDIDATES[normalized]; + if (candidates) { + for (const envVar of candidates) { + const resolved = pick(envVar); + if (resolved) { + return resolved; + } + } } if (normalized === "google-vertex") { @@ -304,65 +300,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { } return { apiKey: envKey, source: "gcloud adc" }; } - - if (normalized === "opencode") { - return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY"); - } - - if (normalized === "qwen-portal") { - return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY"); - } - - if (normalized === "volcengine" || normalized === "volcengine-plan") { - return pick("VOLCANO_ENGINE_API_KEY"); - } - - if (normalized === "byteplus" || normalized === "byteplus-plan") { - return pick("BYTEPLUS_API_KEY"); - } - if (normalized === "minimax-portal") { - return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY"); - } - - if (normalized === "kimi-coding") { - return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY"); - } - - if (normalized === "huggingface") { - return pick("HUGGINGFACE_HUB_TOKEN") ?? pick("HF_TOKEN"); - } - - const envMap: Record = { - openai: "OPENAI_API_KEY", - google: "GEMINI_API_KEY", - voyage: "VOYAGE_API_KEY", - groq: "GROQ_API_KEY", - deepgram: "DEEPGRAM_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_API_KEY", - openrouter: "OPENROUTER_API_KEY", - litellm: "LITELLM_API_KEY", - "vercel-ai-gateway": "AI_GATEWAY_API_KEY", - "cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY", - moonshot: "MOONSHOT_API_KEY", - minimax: "MINIMAX_API_KEY", - nvidia: "NVIDIA_API_KEY", - xiaomi: "XIAOMI_API_KEY", - synthetic: "SYNTHETIC_API_KEY", - venice: "VENICE_API_KEY", - mistral: "MISTRAL_API_KEY", - opencode: "OPENCODE_API_KEY", - together: "TOGETHER_API_KEY", - qianfan: "QIANFAN_API_KEY", - ollama: "OLLAMA_API_KEY", - vllm: "VLLM_API_KEY", - kilocode: "KILOCODE_API_KEY", - }; - const envVar = envMap[normalized]; - if (!envVar) { - return null; - } - return pick(envVar); + return null; } export function resolveModelAuthMode( diff --git a/src/agents/model-fallback.probe.test.ts b/src/agents/model-fallback.probe.test.ts index 8dafd6533da..bcb66628d66 100644 --- a/src/agents/model-fallback.probe.test.ts +++ b/src/agents/model-fallback.probe.test.ts @@ -57,6 +57,47 @@ function expectPrimaryProbeSuccess( }); } +async function expectProbeFailureFallsBack({ + reason, + probeError, +}: { + reason: "rate_limit" | "overloaded"; + probeError: Error & { status: number }; +}) { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], + }, + }, + }, + } as Partial); + + mockedIsProfileInCooldown.mockReturnValue(true); + mockedGetSoonestCooldownExpiry.mockReturnValue(1_700_000_000_000 + 30 * 1000); + mockedResolveProfilesUnavailableReason.mockReturnValue(reason); + + const run = vi.fn().mockRejectedValueOnce(probeError).mockResolvedValue("fallback-ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("fallback-ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { + allowTransientCooldownProbe: true, + }); + expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", { + allowTransientCooldownProbe: true, + }); +} + describe("runWithModelFallback – probe logic", () => { let realDateNow: () => number; const NOW = 1_700_000_000_000; @@ -166,82 +207,16 @@ describe("runWithModelFallback – probe logic", () => { }); it("attempts non-primary fallbacks during rate-limit cooldown after primary probe failure", async () => { - const cfg = makeCfg({ - agents: { - defaults: { - model: { - primary: "openai/gpt-4.1-mini", - fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], - }, - }, - }, - } as Partial); - - // Override: ALL providers in cooldown for this test - mockedIsProfileInCooldown.mockReturnValue(true); - - // All profiles in cooldown, cooldown just about to expire - const almostExpired = NOW + 30 * 1000; // 30s remaining - mockedGetSoonestCooldownExpiry.mockReturnValue(almostExpired); - - // Primary probe fails with 429; fallback should still be attempted for rate_limit cooldowns. - const run = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) - .mockResolvedValue("fallback-ok"); - - const result = await runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - run, - }); - - expect(result.result).toBe("fallback-ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { - allowTransientCooldownProbe: true, - }); - expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", { - allowTransientCooldownProbe: true, + await expectProbeFailureFallsBack({ + reason: "rate_limit", + probeError: Object.assign(new Error("rate limited"), { status: 429 }), }); }); it("attempts non-primary fallbacks during overloaded cooldown after primary probe failure", async () => { - const cfg = makeCfg({ - agents: { - defaults: { - model: { - primary: "openai/gpt-4.1-mini", - fallbacks: ["anthropic/claude-haiku-3-5", "google/gemini-2-flash"], - }, - }, - }, - } as Partial); - - mockedIsProfileInCooldown.mockReturnValue(true); - mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 1000); - mockedResolveProfilesUnavailableReason.mockReturnValue("overloaded"); - - const run = vi - .fn() - .mockRejectedValueOnce(Object.assign(new Error("service overloaded"), { status: 503 })) - .mockResolvedValue("fallback-ok"); - - const result = await runWithModelFallback({ - cfg, - provider: "openai", - model: "gpt-4.1-mini", - run, - }); - - expect(result.result).toBe("fallback-ok"); - expect(run).toHaveBeenCalledTimes(2); - expect(run).toHaveBeenNthCalledWith(1, "openai", "gpt-4.1-mini", { - allowTransientCooldownProbe: true, - }); - expect(run).toHaveBeenNthCalledWith(2, "anthropic", "claude-haiku-3-5", { - allowTransientCooldownProbe: true, + await expectProbeFailureFallsBack({ + reason: "overloaded", + probeError: Object.assign(new Error("service overloaded"), { status: 503 }), }); }); diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 61afb89c6bb..2e5a8202e95 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -95,6 +95,7 @@ const makeAttempt = (overrides: Partial): EmbeddedRunA }); function makeConfig(): OpenClawConfig { + const apiKeyField = ["api", "Key"].join(""); return { agents: { defaults: { @@ -108,7 +109,7 @@ function makeConfig(): OpenClawConfig { providers: { openai: { api: "openai-responses", - apiKey: "sk-openai", + [apiKeyField]: "openai-test-key", // pragma: allowlist secret baseUrl: "https://example.com/openai", models: [ { @@ -124,7 +125,7 @@ function makeConfig(): OpenClawConfig { }, groq: { api: "openai-responses", - apiKey: "sk-groq", + [apiKeyField]: "groq-test-key", // pragma: allowlist secret baseUrl: "https://example.com/groq", models: [ { @@ -228,6 +229,10 @@ async function runEmbeddedFallback(params: { } function mockPrimaryOverloadedThenFallbackSuccess() { + mockPrimaryErrorThenFallbackSuccess(OVERLOADED_ERROR_PAYLOAD); +} + +function mockPrimaryErrorThenFallbackSuccess(errorMessage: string) { runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { const attemptParams = params as { provider: string; modelId: string; authProfileId?: string }; if (attemptParams.provider === "openai") { @@ -237,7 +242,7 @@ function mockPrimaryOverloadedThenFallbackSuccess() { provider: "openai", model: "mock-1", stopReason: "error", - errorMessage: OVERLOADED_ERROR_PAYLOAD, + errorMessage, }), }); } @@ -256,6 +261,21 @@ function mockPrimaryOverloadedThenFallbackSuccess() { }); } +function expectOpenAiThenGroqAttemptOrder(params?: { expectOpenAiAuthProfileId?: string }) { + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); + const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as + | { provider?: string; authProfileId?: string } + | undefined; + const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as { provider?: string } | undefined; + expect(firstCall).toBeDefined(); + expect(secondCall).toBeDefined(); + expect(firstCall?.provider).toBe("openai"); + if (params?.expectOpenAiAuthProfileId) { + expect(firstCall?.authProfileId).toBe(params.expectOpenAiAuthProfileId); + } + expect(secondCall?.provider).toBe("groq"); +} + function mockAllProvidersOverloaded() { runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { const attemptParams = params as { provider: string; modelId: string; authProfileId?: string }; @@ -297,17 +317,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => { expect(usageStats["openai:p1"]?.failureCounts).toMatchObject({ overloaded: 1 }); expect(typeof usageStats["groq:p1"]?.lastUsed).toBe("number"); - expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); - const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as - | { provider?: string } - | undefined; - const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as - | { provider?: string } - | undefined; - expect(firstCall).toBeDefined(); - expect(secondCall).toBeDefined(); - expect(firstCall?.provider).toBe("openai"); - expect(secondCall?.provider).toBe("groq"); + expectOpenAiThenGroqAttemptOrder(); expect(computeBackoffMock).toHaveBeenCalledTimes(1); expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); }); @@ -371,18 +381,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => { }); expect(result.provider).toBe("groq"); - expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); - const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as - | { provider?: string; authProfileId?: string } - | undefined; - const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as - | { provider?: string } - | undefined; - expect(firstCall).toBeDefined(); - expect(secondCall).toBeDefined(); - expect(firstCall?.provider).toBe("openai"); - expect(firstCall?.authProfileId).toBe("openai:p1"); - expect(secondCall?.provider).toBe("groq"); + expectOpenAiThenGroqAttemptOrder({ expectOpenAiAuthProfileId: "openai:p1" }); }); }); @@ -414,19 +413,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => { }); expect(secondResult.provider).toBe("groq"); - expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2); - - const firstCall = runEmbeddedAttemptMock.mock.calls[0]?.[0] as - | { provider?: string; authProfileId?: string } - | undefined; - const secondCall = runEmbeddedAttemptMock.mock.calls[1]?.[0] as - | { provider?: string } - | undefined; - expect(firstCall).toBeDefined(); - expect(secondCall).toBeDefined(); - expect(firstCall?.provider).toBe("openai"); - expect(firstCall?.authProfileId).toBe("openai:p1"); - expect(secondCall?.provider).toBe("groq"); + expectOpenAiThenGroqAttemptOrder({ expectOpenAiAuthProfileId: "openai:p1" }); const usageStats = await readUsageStats(agentDir); expect(typeof usageStats["openai:p1"]?.cooldownUntil).toBe("number"); @@ -439,32 +426,7 @@ describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => { it("keeps bare service-unavailable failures in the timeout lane without persisting cooldown", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); - runEmbeddedAttemptMock.mockImplementation(async (params: unknown) => { - const attemptParams = params as { provider: string }; - if (attemptParams.provider === "openai") { - return makeAttempt({ - assistantTexts: [], - lastAssistant: buildAssistant({ - provider: "openai", - model: "mock-1", - stopReason: "error", - errorMessage: "LLM error: service unavailable", - }), - }); - } - if (attemptParams.provider === "groq") { - return makeAttempt({ - assistantTexts: ["fallback ok"], - lastAssistant: buildAssistant({ - provider: "groq", - model: "mock-2", - stopReason: "stop", - content: [{ type: "text", text: "fallback ok" }], - }), - }); - } - throw new Error(`Unexpected provider ${attemptParams.provider}`); - }); + mockPrimaryErrorThenFallbackSuccess("LLM error: service unavailable"); const result = await runEmbeddedFallback({ agentDir, diff --git a/src/agents/models-config.file-mode.test.ts b/src/agents/models-config.file-mode.test.ts new file mode 100644 index 00000000000..af5719082da --- /dev/null +++ b/src/agents/models-config.file-mode.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; + +installModelsConfigTestHooks(); + +describe("models-config file mode", () => { + it("writes models.json with mode 0600", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); + + it("repairs models.json mode to 0600 on no-content-change paths", async () => { + if (process.platform === "win32") { + return; + } + await withTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + await fs.chmod(modelsPath, 0o644); + + const result = await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + expect(result.wrote).toBe(false); + + const stat = await fs.stat(modelsPath); + expect(stat.mode & 0o777).toBe(0o600); + }); + }); +}); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index bb3ca7a7cbe..a7277736581 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { validateConfigObject } from "../config/validation.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { CUSTOM_PROXY_MODELS_CONFIG, installModelsConfigTestHooks, @@ -43,7 +44,7 @@ async function writeAgentModelsJson(content: unknown): Promise { function createMergeConfigProvider() { return { baseUrl: "https://config.example/v1", - apiKey: "CONFIG_KEY", + apiKey: "CONFIG_KEY", // pragma: allowlist secret api: "openai-responses" as const, models: [ { @@ -114,7 +115,7 @@ describe("models-config", () => { providers: { anthropic: { baseUrl: "https://relay.example.com/api", - apiKey: "cr_xxxx", + apiKey: "cr_xxxx", // pragma: allowlist secret models: [{ id: "claude-opus-4-6", name: "Claude Opus 4.6" }], }, }, @@ -166,7 +167,7 @@ describe("models-config", () => { const parsed = await readGeneratedModelsJson<{ providers: Record }>; }>(); - expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret const ids = parsed.providers.minimax?.models?.map((model) => model.id); expect(ids).toContain("MiniMax-VL-01"); }); @@ -178,7 +179,7 @@ describe("models-config", () => { providers: { existing: { baseUrl: "http://localhost:1234/v1", - apiKey: "EXISTING_KEY", + apiKey: "EXISTING_KEY", // pragma: allowlist secret api: "openai-completions", models: [ { @@ -211,7 +212,7 @@ describe("models-config", () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ baseUrl: "https://agent.example/v1", - apiKey: "AGENT_KEY", + apiKey: "AGENT_KEY", // pragma: allowlist secret api: "openai-responses", models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], }); @@ -220,6 +221,117 @@ describe("models-config", () => { }); }); + it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: { source: "env", provider: "default", id: "CUSTOM_PROVIDER_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("CUSTOM_PROVIDER_API_KEY"); // pragma: allowlist secret + expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1"); + }); + }); + + it("replaces stale merged apiKey when provider is SecretRef-managed via auth-profiles", async () => { + await withTempHome(async () => { + const agentDir = resolveOpenClawAgentDir(); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "minimax:default": { + type: "api_key", + provider: "minimax", + keyRef: { source: "env", provider: "default", id: "MINIMAX_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + await writeAgentModelsJson({ + providers: { + minimax: { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret + api: "anthropic-messages", + models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: {}, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY"); // pragma: allowlist secret + }); + }); + + it("replaces stale non-env marker when provider transitions back to plaintext config", async () => { + await withTempHome(async () => { + await writeAgentModelsJson({ + providers: { + custom: { + baseUrl: "https://agent.example/v1", + apiKey: NON_ENV_SECRETREF_MARKER, + api: "openai-responses", + models: [{ id: "agent-model", name: "Agent model", input: ["text"] }], + }, + }, + }); + + await ensureOpenClawModelsJson({ + models: { + mode: "merge", + providers: { + custom: { + ...createMergeConfigProvider(), + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + }, + }, + }, + }); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.custom?.apiKey).toBe("ALLCAPS_SAMPLE"); + }); + }); + it("uses config apiKey/baseUrl when existing agent values are empty", async () => { await withTempHome(async () => { const parsed = await runCustomProviderMergeTest({ diff --git a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts index 437b84be3a7..2874209d9c2 100644 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ b/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts @@ -14,7 +14,7 @@ describe("models-config", () => { providers: { google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", - apiKey: "GEMINI_KEY", + apiKey: "GEMINI_KEY", // pragma: allowlist secret api: "google-generative-ai", models: [ { diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts new file mode 100644 index 00000000000..0a606762d66 --- /dev/null +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -0,0 +1,121 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { + MINIMAX_OAUTH_MARKER, + NON_ENV_SECRETREF_MARKER, + QWEN_OAUTH_MARKER, +} from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("models-config provider auth provenance", () => { + it("persists env keyRef and tokenRef auth profiles as env var markers", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY", "TOGETHER_API_KEY"]); + delete process.env.VOLCANO_ENGINE_API_KEY; + delete process.env.TOGETHER_API_KEY; + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "volcengine:default": { + type: "api_key", + provider: "volcengine", + keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" }, + }, + "together:default": { + type: "token", + provider: "together", + tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY"); + expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for ref-managed profiles even when runtime plaintext is present", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "byteplus:default": { + type: "api_key", + provider: "byteplus", + key: "sk-runtime-resolved-byteplus", + keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" }, + }, + "together:default": { + type: "token", + provider: "together", + token: "tok-runtime-resolved-together", + tokenRef: { source: "exec", provider: "vault", id: "providers/together/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); + + it("keeps oauth compatibility markers for minimax-portal and qwen-portal", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "qwen-portal:default": { + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER); + expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts new file mode 100644 index 00000000000..82a16dbcbee --- /dev/null +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("cloudflare-ai-gateway profile provenance", () => { + it("prefers env keyRef marker over runtime plaintext for persistence", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]); + delete process.env.CLOUDFLARE_AI_GATEWAY_API_KEY; + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "env", provider: "default", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + try { + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); + } finally { + envSnapshot.restore(); + } + }); + + it("uses non-env marker for non-env keyRef cloudflare profiles", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-runtime-cloudflare", + keyRef: { source: "file", provider: "vault", id: "/cloudflare/apiKey" }, + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); +}); diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts new file mode 100644 index 00000000000..6e8ebfbc0ac --- /dev/null +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { resolveImplicitProviders } from "./models-config.providers.js"; + +describe("provider discovery auth marker guardrails", () => { + let originalVitest: string | undefined; + let originalNodeEnv: string | undefined; + let originalFetch: typeof globalThis.fetch | undefined; + + afterEach(() => { + if (originalVitest !== undefined) { + process.env.VITEST = originalVitest; + } else { + delete process.env.VITEST; + } + if (originalNodeEnv !== undefined) { + process.env.NODE_ENV = originalNodeEnv; + } else { + delete process.env.NODE_ENV; + } + if (originalFetch) { + globalThis.fetch = originalFetch; + } + }); + + function enableDiscovery() { + originalVitest = process.env.VITEST; + originalNodeEnv = process.env.NODE_ENV; + originalFetch = globalThis.fetch; + delete process.env.VITEST; + delete process.env.NODE_ENV; + } + + it("does not send marker value as vLLM bearer token during discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + keyRef: { source: "file", provider: "vault", id: "/vllm/apiKey" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const request = fetchMock.mock.calls[0]?.[1] as + | { headers?: Record } + | undefined; + expect(request?.headers?.Authorization).toBeUndefined(); + }); + + it("does not call Hugging Face discovery with marker-backed credentials", async () => { + enableDiscovery(); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "huggingface:default": { + type: "api_key", + provider: "huggingface", + keyRef: { source: "exec", provider: "vault", id: "providers/hf/token" }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProviders({ agentDir }); + expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("router.huggingface.co"), + ); + expect(huggingfaceCalls).toHaveLength(0); + }); + + it("keeps all-caps plaintext API keys for authenticated discovery", async () => { + enableDiscovery(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "vllm/test-model" }] }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "vllm:default": { + type: "api_key", + provider: "vllm", + key: "ALLCAPS_SAMPLE", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await resolveImplicitProviders({ agentDir }); + const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000")); + const request = vllmCall?.[1] as { headers?: Record } | undefined; + expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE"); + }); +}); diff --git a/src/agents/models-config.providers.google-antigravity.test.ts b/src/agents/models-config.providers.google-antigravity.test.ts index 51fe5fb32e0..6879a392277 100644 --- a/src/agents/models-config.providers.google-antigravity.test.ts +++ b/src/agents/models-config.providers.google-antigravity.test.ts @@ -24,7 +24,7 @@ function buildProvider(modelIds: string[]): ProviderConfig { return { baseUrl: "https://example.invalid/v1", api: "openai-completions", - apiKey: "EXAMPLE_KEY", + apiKey: "EXAMPLE_KEY", // pragma: allowlist secret models: modelIds.map((id) => buildModel(id)), }; } diff --git a/src/agents/models-config.providers.kilocode.test.ts b/src/agents/models-config.providers.kilocode.test.ts index 2cbb3b2609f..ce57ab561be 100644 --- a/src/agents/models-config.providers.kilocode.test.ts +++ b/src/agents/models-config.providers.kilocode.test.ts @@ -11,7 +11,7 @@ describe("Kilo Gateway implicit provider", () => { it("should include kilocode when KILOCODE_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KILOCODE_API_KEY"]); - process.env.KILOCODE_API_KEY = "test-key"; + process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts index ff0c010489b..bc49115cb40 100644 --- a/src/agents/models-config.providers.kimi-coding.test.ts +++ b/src/agents/models-config.providers.kimi-coding.test.ts @@ -9,7 +9,7 @@ describe("kimi-coding implicit provider (#22409)", () => { it("should include kimi-coding when KIMI_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); - process.env.KIMI_API_KEY = "test-key"; + process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index cccd54851d8..820e9169f26 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { normalizeProviders } from "./models-config.providers.js"; describe("normalizeProviders", () => { @@ -13,7 +14,7 @@ describe("normalizeProviders", () => { " dashscope-vision ": { baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", api: "openai-completions", - apiKey: "DASHSCOPE_API_KEY", + apiKey: "DASHSCOPE_API_KEY", // pragma: allowlist secret models: [ { id: "qwen-vl-max", @@ -43,13 +44,13 @@ describe("normalizeProviders", () => { openai: { baseUrl: "https://api.openai.com/v1", api: "openai-completions", - apiKey: "OPENAI_API_KEY", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret models: [], }, " openai ": { baseUrl: "https://example.com/v1", api: "openai-completions", - apiKey: "CUSTOM_OPENAI_API_KEY", + apiKey: "CUSTOM_OPENAI_API_KEY", // pragma: allowlist secret models: [ { id: "gpt-4.1-mini", @@ -73,4 +74,30 @@ describe("normalizeProviders", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("normalizes SecretRef-backed provider headers to non-secret marker values", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + try { + const providers: NonNullable["providers"]> = { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { source: "env", provider: "default", id: "OPENAI_HEADER_TOKEN" }, + "X-Tenant-Token": { source: "file", provider: "vault", id: "/openai/token" }, + }, + models: [], + }, + }; + + const normalized = normalizeProviders({ + providers, + agentDir, + }); + expect(normalized?.openai?.headers?.Authorization).toBe("secretref-env:OPENAI_HEADER_TOKEN"); + expect(normalized?.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index 9531e20e7eb..661c95c1c8e 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -51,7 +51,7 @@ describe("Ollama provider", () => { }; async function withOllamaApiKey(run: () => Promise): Promise { - process.env.OLLAMA_API_KEY = "test-key"; + process.env.OLLAMA_API_KEY = "test-key"; // pragma: allowlist secret try { return await run(); } finally { @@ -245,7 +245,7 @@ describe("Ollama provider", () => { ollama: { baseUrl: "http://remote-ollama:11434/v1", models: explicitModels, - apiKey: "config-ollama-key", + apiKey: "config-ollama-key", // pragma: allowlist secret }, }, }); @@ -271,7 +271,7 @@ describe("Ollama provider", () => { baseUrl: "http://remote-ollama:11434/v1", api: "openai-completions", models: [], - apiKey: "config-ollama-key", + apiKey: "config-ollama-key", // pragma: allowlist secret }, }, }); diff --git a/src/agents/models-config.providers.qianfan.test.ts b/src/agents/models-config.providers.qianfan.test.ts index 081b0aeb710..97c093d7c47 100644 --- a/src/agents/models-config.providers.qianfan.test.ts +++ b/src/agents/models-config.providers.qianfan.test.ts @@ -5,10 +5,14 @@ import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveImplicitProviders } from "./models-config.providers.js"; +const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_"); + describe("Qianfan provider", () => { it("should include qianfan when QIANFAN_API_KEY is configured", async () => { + // pragma: allowlist secret const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); - await withEnvAsync({ QIANFAN_API_KEY: "test-key" }, async () => { + const qianfanApiKey = "test-key"; // pragma: allowlist secret + await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => { const providers = await resolveImplicitProviders({ agentDir }); expect(providers?.qianfan).toBeDefined(); expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY"); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 1c7ad06699c..62bdf70f04e 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { coerceSecretRef } from "../config/types.secrets.js"; +import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { DEFAULT_COPILOT_API_BASE_URL, @@ -41,6 +41,15 @@ import { buildHuggingfaceModelDefinition, } from "./huggingface-models.js"; import { discoverKilocodeModels } from "./kilocode-models.js"; +import { + MINIMAX_OAUTH_MARKER, + OLLAMA_LOCAL_AUTH_MARKER, + QWEN_OAUTH_MARKER, + isNonSecretApiKeyMarker, + resolveNonEnvSecretRefApiKeyMarker, + resolveNonEnvSecretRefHeaderValueMarker, + resolveEnvSecretRefHeaderValueMarker, +} from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js"; import { @@ -63,7 +72,6 @@ const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; -const MINIMAX_OAUTH_PLACEHOLDER = "minimax-oauth"; // Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price const MINIMAX_API_COST = { input: 0.3, @@ -133,7 +141,6 @@ const KIMI_CODING_DEFAULT_COST = { }; const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1"; -const QWEN_PORTAL_OAUTH_PLACEHOLDER = "qwen-oauth"; const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000; const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192; const QWEN_PORTAL_DEFAULT_COST = { @@ -404,35 +411,125 @@ function resolveAwsSdkApiKeyVarName(): string { return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE"; } +function normalizeHeaderValues(params: { + headers: ProviderConfig["headers"] | undefined; + secretDefaults: + | { + env?: string; + file?: string; + exec?: string; + } + | undefined; +}): { headers: ProviderConfig["headers"] | undefined; mutated: boolean } { + const { headers } = params; + if (!headers) { + return { headers, mutated: false }; + } + let mutated = false; + const nextHeaders: Record[string]> = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + const resolvedRef = resolveSecretInputRef({ + value: headerValue, + defaults: params.secretDefaults, + }).ref; + if (!resolvedRef || !resolvedRef.id.trim()) { + nextHeaders[headerName] = headerValue; + continue; + } + mutated = true; + nextHeaders[headerName] = + resolvedRef.source === "env" + ? resolveEnvSecretRefHeaderValueMarker(resolvedRef.id) + : resolveNonEnvSecretRefHeaderValueMarker(resolvedRef.source); + } + if (!mutated) { + return { headers, mutated: false }; + } + return { headers: nextHeaders, mutated: true }; +} + +type ProfileApiKeyResolution = { + apiKey: string; + source: "plaintext" | "env-ref" | "non-env-ref"; + /** Optional secret value that may be used for provider discovery only. */ + discoveryApiKey?: string; +}; + +function toDiscoveryApiKey(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || isNonSecretApiKeyMarker(trimmed)) { + return undefined; + } + return trimmed; +} + +function resolveApiKeyFromCredential( + cred: ReturnType["profiles"][string] | undefined, +): ProfileApiKeyResolution | undefined { + if (!cred) { + return undefined; + } + if (cred.type === "api_key") { + const keyRef = coerceSecretRef(cred.keyRef); + if (keyRef && keyRef.id.trim()) { + if (keyRef.source === "env") { + const envVar = keyRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(keyRef.source), + source: "non-env-ref", + }; + } + if (cred.key?.trim()) { + return { + apiKey: cred.key, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.key), + }; + } + return undefined; + } + if (cred.type === "token") { + const tokenRef = coerceSecretRef(cred.tokenRef); + if (tokenRef && tokenRef.id.trim()) { + if (tokenRef.source === "env") { + const envVar = tokenRef.id.trim(); + return { + apiKey: envVar, + source: "env-ref", + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + return { + apiKey: resolveNonEnvSecretRefApiKeyMarker(tokenRef.source), + source: "non-env-ref", + }; + } + if (cred.token?.trim()) { + return { + apiKey: cred.token, + source: "plaintext", + discoveryApiKey: toDiscoveryApiKey(cred.token), + }; + } + } + return undefined; +} + function resolveApiKeyFromProfiles(params: { provider: string; store: ReturnType; -}): string | undefined { +}): ProfileApiKeyResolution | undefined { const ids = listProfilesForProvider(params.store, params.provider); for (const id of ids) { - const cred = params.store.profiles[id]; - if (!cred) { - continue; - } - if (cred.type === "api_key") { - if (cred.key?.trim()) { - return cred.key; - } - const keyRef = coerceSecretRef(cred.keyRef); - if (keyRef?.source === "env" && keyRef.id.trim()) { - return keyRef.id.trim(); - } - continue; - } - if (cred.type === "token") { - if (cred.token?.trim()) { - return cred.token; - } - const tokenRef = coerceSecretRef(cred.tokenRef); - if (tokenRef?.source === "env" && tokenRef.id.trim()) { - return tokenRef.id.trim(); - } - continue; + const resolved = resolveApiKeyFromCredential(params.store.profiles[id]); + if (resolved) { + return resolved; } } return undefined; @@ -484,6 +581,12 @@ function normalizeAntigravityProvider(provider: ProviderConfig): ProviderConfig export function normalizeProviders(params: { providers: ModelsConfig["providers"]; agentDir: string; + secretDefaults?: { + env?: string; + file?: string; + exec?: string; + }; + secretRefManagedProviders?: Set; }): ModelsConfig["providers"] { const { providers } = params; if (!providers) { @@ -505,18 +608,51 @@ export function normalizeProviders(params: { mutated = true; } let normalizedProvider = provider; - const configuredApiKey = normalizedProvider.apiKey; - - // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". - if ( - typeof configuredApiKey === "string" && - normalizeApiKeyConfig(configuredApiKey) !== configuredApiKey - ) { + const normalizedHeaders = normalizeHeaderValues({ + headers: normalizedProvider.headers, + secretDefaults: params.secretDefaults, + }); + if (normalizedHeaders.mutated) { mutated = true; - normalizedProvider = { - ...normalizedProvider, - apiKey: normalizeApiKeyConfig(configuredApiKey), - }; + normalizedProvider = { ...normalizedProvider, headers: normalizedHeaders.headers }; + } + const configuredApiKey = normalizedProvider.apiKey; + const configuredApiKeyRef = resolveSecretInputRef({ + value: configuredApiKey, + defaults: params.secretDefaults, + }).ref; + const profileApiKey = resolveApiKeyFromProfiles({ + provider: normalizedKey, + store: authStore, + }); + + if (configuredApiKeyRef && configuredApiKeyRef.id.trim()) { + const marker = + configuredApiKeyRef.source === "env" + ? configuredApiKeyRef.id.trim() + : resolveNonEnvSecretRefApiKeyMarker(configuredApiKeyRef.source); + if (normalizedProvider.apiKey !== marker) { + mutated = true; + normalizedProvider = { ...normalizedProvider, apiKey: marker }; + } + params.secretRefManagedProviders?.add(normalizedKey); + } else if (typeof configuredApiKey === "string") { + // Fix common misconfig: apiKey set to "${ENV_VAR}" instead of "ENV_VAR". + const normalizedConfiguredApiKey = normalizeApiKeyConfig(configuredApiKey); + if (normalizedConfiguredApiKey !== configuredApiKey) { + mutated = true; + normalizedProvider = { + ...normalizedProvider, + apiKey: normalizedConfiguredApiKey, + }; + } + if ( + profileApiKey && + profileApiKey.source !== "plaintext" && + normalizedConfiguredApiKey === profileApiKey.apiKey + ) { + params.secretRefManagedProviders?.add(normalizedKey); + } } // If a provider defines models, pi's ModelRegistry requires apiKey to be set. @@ -534,12 +670,11 @@ export function normalizeProviders(params: { normalizedProvider = { ...normalizedProvider, apiKey }; } else { const fromEnv = resolveEnvApiKeyVarName(normalizedKey); - const fromProfiles = resolveApiKeyFromProfiles({ - provider: normalizedKey, - store: authStore, - }); - const apiKey = fromEnv ?? fromProfiles; + const apiKey = fromEnv ?? profileApiKey?.apiKey; if (apiKey?.trim()) { + if (profileApiKey && profileApiKey.source !== "plaintext") { + params.secretRefManagedProviders?.add(normalizedKey); + } mutated = true; normalizedProvider = { ...normalizedProvider, apiKey }; } @@ -778,14 +913,8 @@ async function buildOllamaProvider( }; } -async function buildHuggingfaceProvider(apiKey?: string): Promise { - // Resolve env var name to value for discovery (GET /v1/models requires Bearer token). - const resolvedSecret = - apiKey?.trim() !== "" - ? /^[A-Z][A-Z0-9_]*$/.test(apiKey!.trim()) - ? (process.env[apiKey!.trim()] ?? "").trim() - : apiKey!.trim() - : ""; +async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise { + const resolvedSecret = toDiscoveryApiKey(discoveryApiKey) ?? ""; const models = resolvedSecret !== "" ? await discoverHuggingfaceModels(resolvedSecret) @@ -946,10 +1075,24 @@ export async function resolveImplicitProviders(params: { const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); + const resolveProviderApiKey = ( + provider: string, + ): { apiKey: string | undefined; discoveryApiKey?: string } => { + const envVar = resolveEnvApiKeyVarName(provider); + if (envVar) { + return { + apiKey: envVar, + discoveryApiKey: toDiscoveryApiKey(process.env[envVar]), + }; + } + const fromProfiles = resolveApiKeyFromProfiles({ provider, store: authStore }); + return { + apiKey: fromProfiles?.apiKey, + discoveryApiKey: fromProfiles?.discoveryApiKey, + }; + }; - const minimaxKey = - resolveEnvApiKeyVarName("minimax") ?? - resolveApiKeyFromProfiles({ provider: "minimax", store: authStore }); + const minimaxKey = resolveProviderApiKey("minimax").apiKey; if (minimaxKey) { providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey }; } @@ -958,34 +1101,26 @@ export async function resolveImplicitProviders(params: { if (minimaxOauthProfile.length > 0) { providers["minimax-portal"] = { ...buildMinimaxPortalProvider(), - apiKey: MINIMAX_OAUTH_PLACEHOLDER, + apiKey: MINIMAX_OAUTH_MARKER, }; } - const moonshotKey = - resolveEnvApiKeyVarName("moonshot") ?? - resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore }); + const moonshotKey = resolveProviderApiKey("moonshot").apiKey; if (moonshotKey) { providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey }; } - const kimiCodingKey = - resolveEnvApiKeyVarName("kimi-coding") ?? - resolveApiKeyFromProfiles({ provider: "kimi-coding", store: authStore }); + const kimiCodingKey = resolveProviderApiKey("kimi-coding").apiKey; if (kimiCodingKey) { providers["kimi-coding"] = { ...buildKimiCodingProvider(), apiKey: kimiCodingKey }; } - const syntheticKey = - resolveEnvApiKeyVarName("synthetic") ?? - resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore }); + const syntheticKey = resolveProviderApiKey("synthetic").apiKey; if (syntheticKey) { providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey }; } - const veniceKey = - resolveEnvApiKeyVarName("venice") ?? - resolveApiKeyFromProfiles({ provider: "venice", store: authStore }); + const veniceKey = resolveProviderApiKey("venice").apiKey; if (veniceKey) { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } @@ -994,13 +1129,11 @@ export async function resolveImplicitProviders(params: { if (qwenProfiles.length > 0) { providers["qwen-portal"] = { ...buildQwenPortalProvider(), - apiKey: QWEN_PORTAL_OAUTH_PLACEHOLDER, + apiKey: QWEN_OAUTH_MARKER, }; } - const volcengineKey = - resolveEnvApiKeyVarName("volcengine") ?? - resolveApiKeyFromProfiles({ provider: "volcengine", store: authStore }); + const volcengineKey = resolveProviderApiKey("volcengine").apiKey; if (volcengineKey) { providers.volcengine = { ...buildDoubaoProvider(), apiKey: volcengineKey }; providers["volcengine-plan"] = { @@ -1009,9 +1142,7 @@ export async function resolveImplicitProviders(params: { }; } - const byteplusKey = - resolveEnvApiKeyVarName("byteplus") ?? - resolveApiKeyFromProfiles({ provider: "byteplus", store: authStore }); + const byteplusKey = resolveProviderApiKey("byteplus").apiKey; if (byteplusKey) { providers.byteplus = { ...buildBytePlusProvider(), apiKey: byteplusKey }; providers["byteplus-plan"] = { @@ -1020,9 +1151,7 @@ export async function resolveImplicitProviders(params: { }; } - const xiaomiKey = - resolveEnvApiKeyVarName("xiaomi") ?? - resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore }); + const xiaomiKey = resolveProviderApiKey("xiaomi").apiKey; if (xiaomiKey) { providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey }; } @@ -1042,7 +1171,9 @@ export async function resolveImplicitProviders(params: { if (!baseUrl) { continue; } - const apiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway") ?? cred.key?.trim() ?? ""; + const envVarApiKey = resolveEnvApiKeyVarName("cloudflare-ai-gateway"); + const profileApiKey = resolveApiKeyFromCredential(cred)?.apiKey; + const apiKey = envVarApiKey ?? profileApiKey ?? ""; if (!apiKey) { continue; } @@ -1059,9 +1190,7 @@ export async function resolveImplicitProviders(params: { // Use the user's configured baseUrl (from explicit providers) for model // discovery so that remote / non-default Ollama instances are reachable. // Skip discovery when explicit models are already defined. - const ollamaKey = - resolveEnvApiKeyVarName("ollama") ?? - resolveApiKeyFromProfiles({ provider: "ollama", store: authStore }); + const ollamaKey = resolveProviderApiKey("ollama").apiKey; const explicitOllama = params.explicitProviders?.ollama; const hasExplicitModels = Array.isArray(explicitOllama?.models) && explicitOllama.models.length > 0; @@ -1070,7 +1199,7 @@ export async function resolveImplicitProviders(params: { ...explicitOllama, baseUrl: resolveOllamaApiBase(explicitOllama.baseUrl), api: explicitOllama.api ?? "ollama", - apiKey: ollamaKey ?? explicitOllama.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } else { const ollamaBaseUrl = explicitOllama?.baseUrl; @@ -1083,7 +1212,7 @@ export async function resolveImplicitProviders(params: { if (ollamaProvider.models.length > 0 || ollamaKey || explicitOllama?.apiKey) { providers.ollama = { ...ollamaProvider, - apiKey: ollamaKey ?? explicitOllama?.apiKey ?? "ollama-local", + apiKey: ollamaKey ?? explicitOllama?.apiKey ?? OLLAMA_LOCAL_AUTH_MARKER, }; } } @@ -1091,23 +1220,16 @@ export async function resolveImplicitProviders(params: { // vLLM provider - OpenAI-compatible local server (opt-in via env/profile). // If explicitly configured, keep user-defined models/settings as-is. if (!params.explicitProviders?.vllm) { - const vllmEnvVar = resolveEnvApiKeyVarName("vllm"); - const vllmProfileKey = resolveApiKeyFromProfiles({ provider: "vllm", store: authStore }); - const vllmKey = vllmEnvVar ?? vllmProfileKey; + const { apiKey: vllmKey, discoveryApiKey } = resolveProviderApiKey("vllm"); if (vllmKey) { - const discoveryApiKey = vllmEnvVar - ? (process.env[vllmEnvVar]?.trim() ?? "") - : (vllmProfileKey ?? ""); providers.vllm = { - ...(await buildVllmProvider({ apiKey: discoveryApiKey || undefined })), + ...(await buildVllmProvider({ apiKey: discoveryApiKey })), apiKey: vllmKey, }; } } - const togetherKey = - resolveEnvApiKeyVarName("together") ?? - resolveApiKeyFromProfiles({ provider: "together", store: authStore }); + const togetherKey = resolveProviderApiKey("together").apiKey; if (togetherKey) { providers.together = { ...buildTogetherProvider(), @@ -1115,41 +1237,32 @@ export async function resolveImplicitProviders(params: { }; } - const huggingfaceKey = - resolveEnvApiKeyVarName("huggingface") ?? - resolveApiKeyFromProfiles({ provider: "huggingface", store: authStore }); + const { apiKey: huggingfaceKey, discoveryApiKey: huggingfaceDiscoveryApiKey } = + resolveProviderApiKey("huggingface"); if (huggingfaceKey) { - const hfProvider = await buildHuggingfaceProvider(huggingfaceKey); + const hfProvider = await buildHuggingfaceProvider(huggingfaceDiscoveryApiKey); providers.huggingface = { ...hfProvider, apiKey: huggingfaceKey, }; } - const qianfanKey = - resolveEnvApiKeyVarName("qianfan") ?? - resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore }); + const qianfanKey = resolveProviderApiKey("qianfan").apiKey; if (qianfanKey) { providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey }; } - const openrouterKey = - resolveEnvApiKeyVarName("openrouter") ?? - resolveApiKeyFromProfiles({ provider: "openrouter", store: authStore }); + const openrouterKey = resolveProviderApiKey("openrouter").apiKey; if (openrouterKey) { providers.openrouter = { ...buildOpenrouterProvider(), apiKey: openrouterKey }; } - const nvidiaKey = - resolveEnvApiKeyVarName("nvidia") ?? - resolveApiKeyFromProfiles({ provider: "nvidia", store: authStore }); + const nvidiaKey = resolveProviderApiKey("nvidia").apiKey; if (nvidiaKey) { providers.nvidia = { ...buildNvidiaProvider(), apiKey: nvidiaKey }; } - const kilocodeKey = - resolveEnvApiKeyVarName("kilocode") ?? - resolveApiKeyFromProfiles({ provider: "kilocode", store: authStore }); + const kilocodeKey = resolveProviderApiKey("kilocode").apiKey; if (kilocodeKey) { providers.kilocode = { ...(await buildKilocodeProviderWithDiscovery()), apiKey: kilocodeKey }; } diff --git a/src/agents/models-config.providers.volcengine-byteplus.test.ts b/src/agents/models-config.providers.volcengine-byteplus.test.ts index 00dd65e38f0..cba28521040 100644 --- a/src/agents/models-config.providers.volcengine-byteplus.test.ts +++ b/src/agents/models-config.providers.volcengine-byteplus.test.ts @@ -10,7 +10,7 @@ describe("Volcengine and BytePlus providers", () => { it("includes volcengine and volcengine-plan when VOLCANO_ENGINE_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["VOLCANO_ENGINE_API_KEY"]); - process.env.VOLCANO_ENGINE_API_KEY = "test-key"; + process.env.VOLCANO_ENGINE_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); @@ -26,7 +26,7 @@ describe("Volcengine and BytePlus providers", () => { it("includes byteplus and byteplus-plan when BYTEPLUS_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["BYTEPLUS_API_KEY"]); - process.env.BYTEPLUS_API_KEY = "test-key"; + process.env.BYTEPLUS_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProviders({ agentDir }); diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts new file mode 100644 index 00000000000..6d6ea0284ee --- /dev/null +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + loadConfig, + setRuntimeConfigSnapshot, +} from "../config/config.js"; +import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; +import { + installModelsConfigTestHooks, + withModelsTempHome as withTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config runtime source snapshot", () => { + it("uses runtime source snapshot markers when passed the active runtime config", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses non-env marker from runtime source snapshot for file refs", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" }, + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-runtime-moonshot", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); + + it("uses header markers from runtime source snapshot instead of resolved runtime values", async () => { + await withTempHome(async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret + }, + "X-Tenant-Token": { + source: "file", + provider: "vault", + id: "/providers/openai/tenantToken", + }, + }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions" as const, + headers: { + Authorization: "Bearer runtime-openai-token", + "X-Tenant-Token": "runtime-tenant-token", + }, + models: [], + }, + }, + }, + }; + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); + + const parsed = await readGeneratedModelsJson<{ + providers: Record }>; + }>(); + expect(parsed.providers.openai?.headers?.Authorization).toBe( + "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret + ); + expect(parsed.providers.openai?.headers?.["X-Tenant-Token"]).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); + }); +}); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 8f840c8a123..ff38fe5e64a 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -97,7 +97,7 @@ describe("models-config", () => { envValue: "sk-minimax-test", providerKey: "minimax", expectedBaseUrl: "https://api.minimax.io/anthropic", - expectedApiKeyRef: "MINIMAX_API_KEY", + expectedApiKeyRef: "MINIMAX_API_KEY", // pragma: allowlist secret expectedModelIds: ["MiniMax-M2.5", "MiniMax-VL-01"], }); }); @@ -110,7 +110,7 @@ describe("models-config", () => { envValue: "sk-synthetic-test", providerKey: "synthetic", expectedBaseUrl: "https://api.synthetic.new/anthropic", - expectedApiKeyRef: "SYNTHETIC_API_KEY", + expectedApiKeyRef: "SYNTHETIC_API_KEY", // pragma: allowlist secret expectedModelIds: ["hf:MiniMaxAI/MiniMax-M2.5"], }); }); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index e31d61044c3..11832b30b15 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,9 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { type OpenClawConfig, loadConfig } from "../config/config.js"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + type OpenClawConfig, + loadConfig, +} from "../config/config.js"; import { applyConfigEnvVars } from "../config/env-vars.js"; import { isRecord } from "../utils.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; +import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { normalizeProviders, type ProviderConfig, @@ -15,6 +21,7 @@ import { type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; +const MODELS_JSON_WRITE_LOCKS = new Map>(); function resolvePreferredTokenLimit(explicitValue: number, implicitValue: number): number { // Keep catalog refresh behavior for stale low values while preserving @@ -141,8 +148,9 @@ async function resolveProvidersForModelsJson(params: { function mergeWithExistingProviderSecrets(params: { nextProviders: Record; existingProviders: Record[string]>; + secretRefManagedProviders: ReadonlySet; }): Record { - const { nextProviders, existingProviders } = params; + const { nextProviders, existingProviders, secretRefManagedProviders } = params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -159,7 +167,12 @@ function mergeWithExistingProviderSecrets(params: { continue; } const preserved: Record = {}; - if (typeof existing.apiKey === "string" && existing.apiKey) { + if ( + !secretRefManagedProviders.has(key) && + typeof existing.apiKey === "string" && + existing.apiKey && + !isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false }) + ) { preserved.apiKey = existing.apiKey; } if (typeof existing.baseUrl === "string" && existing.baseUrl) { @@ -174,6 +187,7 @@ async function resolveProvidersForMode(params: { mode: NonNullable; targetPath: string; providers: Record; + secretRefManagedProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -189,6 +203,7 @@ async function resolveProvidersForMode(params: { return mergeWithExistingProviderSecrets({ nextProviders: params.providers, existingProviders, + secretRefManagedProviders: params.secretRefManagedProviders, }); } @@ -200,45 +215,94 @@ async function readRawFile(pathname: string): Promise { } } +async function ensureModelsFileMode(pathname: string): Promise { + await fs.chmod(pathname, 0o600).catch(() => { + // best-effort + }); +} + +function resolveModelsConfigInput(config?: OpenClawConfig): OpenClawConfig { + const runtimeSource = getRuntimeConfigSourceSnapshot(); + if (!runtimeSource) { + return config ?? loadConfig(); + } + if (!config) { + return runtimeSource; + } + const runtimeResolved = getRuntimeConfigSnapshot(); + if (runtimeResolved && config === runtimeResolved) { + return runtimeSource; + } + return config; +} + +async function withModelsJsonWriteLock(targetPath: string, run: () => Promise): Promise { + const prior = MODELS_JSON_WRITE_LOCKS.get(targetPath) ?? Promise.resolve(); + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const pending = prior.then(() => gate); + MODELS_JSON_WRITE_LOCKS.set(targetPath, pending); + try { + await prior; + return await run(); + } finally { + release(); + if (MODELS_JSON_WRITE_LOCKS.get(targetPath) === pending) { + MODELS_JSON_WRITE_LOCKS.delete(targetPath); + } + } +} + export async function ensureOpenClawModelsJson( config?: OpenClawConfig, agentDirOverride?: string, ): Promise<{ agentDir: string; wrote: boolean }> { - const cfg = config ?? loadConfig(); + const cfg = resolveModelsConfigInput(config); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); - - // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are - // available in process.env before implicit provider discovery. Some - // callers (agent runner, tools) pass config objects that haven't gone - // through the full loadConfig() pipeline which applies these. - applyConfigEnvVars(cfg); - - const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); - - if (Object.keys(providers).length === 0) { - return { agentDir, wrote: false }; - } - - const mode = cfg.models?.mode ?? DEFAULT_MODE; const targetPath = path.join(agentDir, "models.json"); - const mergedProviders = await resolveProvidersForMode({ - mode, - targetPath, - providers, + + return await withModelsJsonWriteLock(targetPath, async () => { + // Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are + // available in process.env before implicit provider discovery. Some + // callers (agent runner, tools) pass config objects that haven't gone + // through the full loadConfig() pipeline which applies these. + applyConfigEnvVars(cfg); + + const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); + + if (Object.keys(providers).length === 0) { + return { agentDir, wrote: false }; + } + + const mode = cfg.models?.mode ?? DEFAULT_MODE; + const secretRefManagedProviders = new Set(); + + const normalizedProviders = + normalizeProviders({ + providers, + agentDir, + secretDefaults: cfg.secrets?.defaults, + secretRefManagedProviders, + }) ?? providers; + const mergedProviders = await resolveProvidersForMode({ + mode, + targetPath, + providers: normalizedProviders, + secretRefManagedProviders, + }); + const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; + const existingRaw = await readRawFile(targetPath); + + if (existingRaw === next) { + await ensureModelsFileMode(targetPath); + return { agentDir, wrote: false }; + } + + await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); + await fs.writeFile(targetPath, next, { mode: 0o600 }); + await ensureModelsFileMode(targetPath); + return { agentDir, wrote: true }; }); - - const normalizedProviders = normalizeProviders({ - providers: mergedProviders, - agentDir, - }); - const next = `${JSON.stringify({ providers: normalizedProviders }, null, 2)}\n`; - const existingRaw = await readRawFile(targetPath); - - if (existingRaw === next) { - return { agentDir, wrote: false }; - } - - await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - await fs.writeFile(targetPath, next, { mode: 0o600 }); - return { agentDir, wrote: true }; } diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts new file mode 100644 index 00000000000..a69fd43b830 --- /dev/null +++ b/src/agents/models-config.write-serialization.test.ts @@ -0,0 +1,55 @@ +import fs from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { + CUSTOM_PROXY_MODELS_CONFIG, + installModelsConfigTestHooks, + withModelsTempHome, +} from "./models-config.e2e-harness.js"; +import { ensureOpenClawModelsJson } from "./models-config.js"; +import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +installModelsConfigTestHooks(); + +describe("models-config write serialization", () => { + it("serializes concurrent models.json writes to avoid overlap", async () => { + await withModelsTempHome(async () => { + const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const firstModel = first.models?.providers?.["custom-proxy"]?.models?.[0]; + const secondModel = second.models?.providers?.["custom-proxy"]?.models?.[0]; + if (!firstModel || !secondModel) { + throw new Error("custom-proxy fixture missing expected model entries"); + } + firstModel.name = "Proxy A"; + secondModel.name = "Proxy B with longer name"; + + const originalWriteFile = fs.writeFile.bind(fs); + let inFlightWrites = 0; + let maxInFlightWrites = 0; + const writeSpy = vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + inFlightWrites += 1; + if (inFlightWrites > maxInFlightWrites) { + maxInFlightWrites = inFlightWrites; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + try { + return await originalWriteFile(...args); + } finally { + inFlightWrites -= 1; + } + }); + + try { + await Promise.all([ensureOpenClawModelsJson(first), ensureOpenClawModelsJson(second)]); + } finally { + writeSpy.mockRestore(); + } + + expect(maxInFlightWrites).toBe(1); + const parsed = await readGeneratedModelsJson<{ + providers: { "custom-proxy"?: { models?: Array<{ name?: string }> } }; + }>(); + expect(parsed.providers["custom-proxy"]?.models?.[0]?.name).toBe("Proxy B with longer name"); + }); + }); +}); diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index db41cd2857a..83c4d3e48d6 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -25,6 +25,23 @@ const JPG_PAYLOAD = { width: 1, height: 1, } as const; +const PHOTOS_LATEST_ACTION_INPUT = { action: "photos_latest", node: NODE_ID } as const; +const PHOTOS_LATEST_DEFAULT_PARAMS = { + limit: 1, + maxWidth: 1600, + quality: 0.85, +} as const; +const PHOTOS_LATEST_PAYLOAD = { + photos: [ + { + format: "jpeg", + base64: "aGVsbG8=", + width: 1, + height: 1, + createdAt: "2026-03-04T00:00:00Z", + }, + ], +} as const; type GatewayCall = { method: string; params?: unknown }; @@ -153,6 +170,25 @@ function setupSystemRunGateway(params: { }); } +function setupPhotosLatestMock(params?: { remoteIp?: string }) { + setupNodeInvokeMock({ + ...(params?.remoteIp ? { remoteIp: params.remoteIp } : {}), + onInvoke: (invokeParams) => { + expect(invokeParams).toMatchObject({ + command: "photos.latest", + params: PHOTOS_LATEST_DEFAULT_PARAMS, + }); + return { payload: PHOTOS_LATEST_PAYLOAD }; + }, + }); +} + +async function executePhotosLatest(params: { modelHasVision: boolean }) { + return executeNodes(PHOTOS_LATEST_ACTION_INPUT, { + modelHasVision: params.modelHasVision, + }); +} + beforeEach(() => { callGateway.mockClear(); vi.unstubAllGlobals(); @@ -377,40 +413,9 @@ describe("nodes photos_latest", () => { }); it("returns MEDIA paths and no inline images when model has no vision", async () => { - setupNodeInvokeMock({ - remoteIp: "198.51.100.42", - onInvoke: (invokeParams) => { - expect(invokeParams).toMatchObject({ - command: "photos.latest", - params: { - limit: 1, - maxWidth: 1600, - quality: 0.85, - }, - }); - return { - payload: { - photos: [ - { - format: "jpeg", - base64: "aGVsbG8=", - width: 1, - height: 1, - createdAt: "2026-03-04T00:00:00Z", - }, - ], - }, - }; - }, - }); + setupPhotosLatestMock({ remoteIp: "198.51.100.42" }); - const result = await executeNodes( - { - action: "photos_latest", - node: NODE_ID, - }, - { modelHasVision: false }, - ); + const result = await executePhotosLatest({ modelHasVision: false }); expectNoImages(result); expect(result.content?.[0]).toMatchObject({ @@ -426,39 +431,9 @@ describe("nodes photos_latest", () => { }); it("includes inline image blocks when model has vision", async () => { - setupNodeInvokeMock({ - onInvoke: (invokeParams) => { - expect(invokeParams).toMatchObject({ - command: "photos.latest", - params: { - limit: 1, - maxWidth: 1600, - quality: 0.85, - }, - }); - return { - payload: { - photos: [ - { - format: "jpeg", - base64: "aGVsbG8=", - width: 1, - height: 1, - createdAt: "2026-03-04T00:00:00Z", - }, - ], - }, - }; - }, - }); + setupPhotosLatestMock(); - const result = await executeNodes( - { - action: "photos_latest", - node: NODE_ID, - }, - { modelHasVision: true }, - ); + const result = await executePhotosLatest({ modelHasVision: true }); expect(result.content?.[0]).toMatchObject({ type: "text", diff --git a/src/agents/owner-display.test.ts b/src/agents/owner-display.test.ts index 42b3d156170..743ee0c31e4 100644 --- a/src/agents/owner-display.test.ts +++ b/src/agents/owner-display.test.ts @@ -13,7 +13,7 @@ describe("resolveOwnerDisplaySetting", () => { expect(resolveOwnerDisplaySetting(cfg)).toEqual({ ownerDisplay: "hash", - ownerDisplaySecret: "owner-secret", + ownerDisplaySecret: "owner-secret", // pragma: allowlist secret }); }); @@ -38,7 +38,7 @@ describe("resolveOwnerDisplaySetting", () => { const cfg = { commands: { ownerDisplay: "raw", - ownerDisplaySecret: "owner-secret", + ownerDisplaySecret: "owner-secret", // pragma: allowlist secret }, } as OpenClawConfig; @@ -67,7 +67,7 @@ describe("ensureOwnerDisplaySecret", () => { const cfg = { commands: { ownerDisplay: "hash", - ownerDisplaySecret: "existing-owner-secret", + ownerDisplaySecret: "existing-owner-secret", // pragma: allowlist secret }, } as OpenClawConfig; diff --git a/src/agents/pi-embedded-block-chunker.test.ts b/src/agents/pi-embedded-block-chunker.test.ts index 0b6c858ef95..c8b1f5dda55 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/pi-embedded-block-chunker.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as fences from "../markdown/fences.js"; import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) { @@ -120,4 +121,20 @@ describe("EmbeddedBlockChunker", () => { expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]); expect(chunker.bufferedText).toBe("After fence"); }); + + it("parses fence spans once per drain call for long fenced buffers", () => { + const parseSpy = vi.spyOn(fences, "parseFenceSpans"); + const chunker = new EmbeddedBlockChunker({ + minChars: 20, + maxChars: 80, + breakPreference: "paragraph", + }); + + chunker.append(`\`\`\`txt\n${"line\n".repeat(600)}\`\`\``); + const chunks = drainChunks(chunker); + + expect(chunks.length).toBeGreaterThan(2); + expect(parseSpy).toHaveBeenCalledTimes(1); + parseSpy.mockRestore(); + }); }); diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts index b1266a1557a..11eddc2d190 100644 --- a/src/agents/pi-embedded-block-chunker.ts +++ b/src/agents/pi-embedded-block-chunker.ts @@ -12,6 +12,7 @@ export type BlockReplyChunking = { type FenceSplit = { closeFenceLine: string; reopenFenceLine: string; + fence: FenceSpan; }; type BreakResult = { @@ -28,6 +29,7 @@ function findSafeSentenceBreakIndex( text: string, fenceSpans: FenceSpan[], minChars: number, + offset = 0, ): number { const matches = text.matchAll(/[.!?](?=\s|$)/g); let sentenceIdx = -1; @@ -37,7 +39,7 @@ function findSafeSentenceBreakIndex( continue; } const candidate = at + 1; - if (isSafeFenceBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, offset + candidate)) { sentenceIdx = candidate; } } @@ -49,8 +51,9 @@ function findSafeParagraphBreakIndex(params: { fenceSpans: FenceSpan[]; minChars: number; reverse: boolean; + offset?: number; }): number { - const { text, fenceSpans, minChars, reverse } = params; + const { text, fenceSpans, minChars, reverse, offset = 0 } = params; let paragraphIdx = reverse ? text.lastIndexOf("\n\n") : text.indexOf("\n\n"); while (reverse ? paragraphIdx >= minChars : paragraphIdx !== -1) { const candidates = [paragraphIdx, paragraphIdx + 1]; @@ -61,7 +64,7 @@ function findSafeParagraphBreakIndex(params: { if (candidate < 0 || candidate >= text.length) { continue; } - if (isSafeFenceBreak(fenceSpans, candidate)) { + if (isSafeFenceBreak(fenceSpans, offset + candidate)) { return candidate; } } @@ -77,11 +80,12 @@ function findSafeNewlineBreakIndex(params: { fenceSpans: FenceSpan[]; minChars: number; reverse: boolean; + offset?: number; }): number { - const { text, fenceSpans, minChars, reverse } = params; + const { text, fenceSpans, minChars, reverse, offset = 0 } = params; let newlineIdx = reverse ? text.lastIndexOf("\n") : text.indexOf("\n"); while (reverse ? newlineIdx >= minChars : newlineIdx !== -1) { - if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, newlineIdx)) { + if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, offset + newlineIdx)) { return newlineIdx; } newlineIdx = reverse @@ -125,14 +129,7 @@ export class EmbeddedBlockChunker { const minChars = Math.max(1, Math.floor(this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); - // When flushOnParagraph is set (chunkMode="newline"), eagerly split on \n\n - // boundaries regardless of minChars so each paragraph is sent immediately. - if (this.#chunking.flushOnParagraph && !force) { - this.#drainParagraphs(emit, maxChars); - return; - } - - if (this.#buffer.length < minChars && !force) { + if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) { return; } @@ -144,108 +141,132 @@ export class EmbeddedBlockChunker { return; } - while (this.#buffer.length >= minChars || (force && this.#buffer.length > 0)) { + const source = this.#buffer; + const fenceSpans = parseFenceSpans(source); + let start = 0; + let reopenFence: FenceSpan | undefined; + + while (start < source.length) { + const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; + const remainingLength = reopenPrefix.length + (source.length - start); + + if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) { + break; + } + + if (this.#chunking.flushOnParagraph && !force) { + const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start); + const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length); + if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) { + const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`; + if (chunk.trim().length > 0) { + emit(chunk); + } + start = skipLeadingNewlines(source, paragraphBreak.index + paragraphBreak.length); + reopenFence = undefined; + continue; + } + if (remainingLength < maxChars) { + break; + } + } + + const view = source.slice(start); const breakResult = - force && this.#buffer.length <= maxChars - ? this.#pickSoftBreakIndex(this.#buffer, 1) - : this.#pickBreakIndex(this.#buffer, force ? 1 : undefined); + force && remainingLength <= maxChars + ? this.#pickSoftBreakIndex(view, fenceSpans, 1, start) + : this.#pickBreakIndex( + view, + fenceSpans, + force || this.#chunking.flushOnParagraph ? 1 : undefined, + start, + ); if (breakResult.index <= 0) { if (force) { - emit(this.#buffer); - this.#buffer = ""; + emit(`${reopenPrefix}${source.slice(start)}`); + start = source.length; + reopenFence = undefined; } - return; + break; } - if (!this.#emitBreakResult(breakResult, emit)) { + const consumed = this.#emitBreakResult({ + breakResult, + emit, + reopenPrefix, + source, + start, + }); + if (consumed === null) { continue; } + start = consumed.start; + reopenFence = consumed.reopenFence; - if (this.#buffer.length < minChars && !force) { - return; + const nextLength = + (reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start); + if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) { + break; } - if (this.#buffer.length < maxChars && !force) { - return; + if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) { + break; } } + this.#buffer = reopenFence + ? `${reopenFence.openLine}\n${source.slice(start)}` + : stripLeadingNewlines(source.slice(start)); } - /** Eagerly emit complete paragraphs (text before \n\n) regardless of minChars. */ - #drainParagraphs(emit: (chunk: string) => void, maxChars: number) { - while (this.#buffer.length > 0) { - const fenceSpans = parseFenceSpans(this.#buffer); - const paragraphBreak = findNextParagraphBreak(this.#buffer, fenceSpans); - if (!paragraphBreak || paragraphBreak.index > maxChars) { - // No paragraph boundary yet (or the next boundary is too far). If the - // buffer exceeds maxChars, fall back to normal break logic to avoid - // oversized chunks or unbounded accumulation. - if (this.#buffer.length >= maxChars) { - const breakResult = this.#pickBreakIndex(this.#buffer, 1); - if (breakResult.index > 0) { - this.#emitBreakResult(breakResult, emit); - continue; - } - } - return; - } - - const chunk = this.#buffer.slice(0, paragraphBreak.index); - if (chunk.trim().length > 0) { - emit(chunk); - } - this.#buffer = stripLeadingNewlines( - this.#buffer.slice(paragraphBreak.index + paragraphBreak.length), - ); - } - } - - #emitBreakResult(breakResult: BreakResult, emit: (chunk: string) => void): boolean { + #emitBreakResult(params: { + breakResult: BreakResult; + emit: (chunk: string) => void; + reopenPrefix: string; + source: string; + start: number; + }): { start: number; reopenFence?: FenceSpan } | null { + const { breakResult, emit, reopenPrefix, source, start } = params; const breakIdx = breakResult.index; if (breakIdx <= 0) { - return false; + return null; } - let rawChunk = this.#buffer.slice(0, breakIdx); + const absoluteBreakIdx = start + breakIdx; + let rawChunk = `${reopenPrefix}${source.slice(start, absoluteBreakIdx)}`; if (rawChunk.trim().length === 0) { - this.#buffer = stripLeadingNewlines(this.#buffer.slice(breakIdx)).trimStart(); - return false; + return { start: skipLeadingNewlines(source, absoluteBreakIdx), reopenFence: undefined }; } - let nextBuffer = this.#buffer.slice(breakIdx); const fenceSplit = breakResult.fenceSplit; if (fenceSplit) { const closeFence = rawChunk.endsWith("\n") ? `${fenceSplit.closeFenceLine}\n` : `\n${fenceSplit.closeFenceLine}\n`; rawChunk = `${rawChunk}${closeFence}`; - - const reopenFence = fenceSplit.reopenFenceLine.endsWith("\n") - ? fenceSplit.reopenFenceLine - : `${fenceSplit.reopenFenceLine}\n`; - nextBuffer = `${reopenFence}${nextBuffer}`; } emit(rawChunk); if (fenceSplit) { - this.#buffer = nextBuffer; - } else { - const nextStart = - breakIdx < this.#buffer.length && /\s/.test(this.#buffer[breakIdx]) - ? breakIdx + 1 - : breakIdx; - this.#buffer = stripLeadingNewlines(this.#buffer.slice(nextStart)); + return { start: absoluteBreakIdx, reopenFence: fenceSplit.fence }; } - return true; + const nextStart = + absoluteBreakIdx < source.length && /\s/.test(source[absoluteBreakIdx]) + ? absoluteBreakIdx + 1 + : absoluteBreakIdx; + return { start: skipLeadingNewlines(source, nextStart), reopenFence: undefined }; } - #pickSoftBreakIndex(buffer: string, minCharsOverride?: number): BreakResult { + #pickSoftBreakIndex( + buffer: string, + fenceSpans: FenceSpan[], + minCharsOverride?: number, + offset = 0, + ): BreakResult { const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars)); if (buffer.length < minChars) { return { index: -1 }; } - const fenceSpans = parseFenceSpans(buffer); const preference = this.#chunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { @@ -254,6 +275,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: false, + offset, }); if (paragraphIdx !== -1) { return { index: paragraphIdx }; @@ -266,6 +288,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: false, + offset, }); if (newlineIdx !== -1) { return { index: newlineIdx }; @@ -273,7 +296,7 @@ export class EmbeddedBlockChunker { } if (preference !== "newline") { - const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars); + const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars, offset); if (sentenceIdx !== -1) { return { index: sentenceIdx }; } @@ -282,14 +305,18 @@ export class EmbeddedBlockChunker { return { index: -1 }; } - #pickBreakIndex(buffer: string, minCharsOverride?: number): BreakResult { + #pickBreakIndex( + buffer: string, + fenceSpans: FenceSpan[], + minCharsOverride?: number, + offset = 0, + ): BreakResult { const minChars = Math.max(1, Math.floor(minCharsOverride ?? this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); if (buffer.length < minChars) { return { index: -1 }; } const window = buffer.slice(0, Math.min(maxChars, buffer.length)); - const fenceSpans = parseFenceSpans(buffer); const preference = this.#chunking.breakPreference ?? "paragraph"; if (preference === "paragraph") { @@ -298,6 +325,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: true, + offset, }); if (paragraphIdx !== -1) { return { index: paragraphIdx }; @@ -310,6 +338,7 @@ export class EmbeddedBlockChunker { fenceSpans, minChars, reverse: true, + offset, }); if (newlineIdx !== -1) { return { index: newlineIdx }; @@ -317,7 +346,7 @@ export class EmbeddedBlockChunker { } if (preference !== "newline") { - const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars); + const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars, offset); if (sentenceIdx !== -1) { return { index: sentenceIdx }; } @@ -328,22 +357,23 @@ export class EmbeddedBlockChunker { } for (let i = window.length - 1; i >= minChars; i--) { - if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, i)) { + if (/\s/.test(window[i]) && isSafeFenceBreak(fenceSpans, offset + i)) { return { index: i }; } } if (buffer.length >= maxChars) { - if (isSafeFenceBreak(fenceSpans, maxChars)) { + if (isSafeFenceBreak(fenceSpans, offset + maxChars)) { return { index: maxChars }; } - const fence = findFenceSpanAt(fenceSpans, maxChars); + const fence = findFenceSpanAt(fenceSpans, offset + maxChars); if (fence) { return { index: maxChars, fenceSplit: { closeFenceLine: `${fence.indent}${fence.marker}`, reopenFenceLine: fence.openLine, + fence, }, }; } @@ -354,12 +384,17 @@ export class EmbeddedBlockChunker { } } -function stripLeadingNewlines(value: string): string { - let i = 0; +function skipLeadingNewlines(value: string, start = 0): number { + let i = start; while (i < value.length && value[i] === "\n") { i++; } - return i > 0 ? value.slice(i) : value; + return i; +} + +function stripLeadingNewlines(value: string): string { + const start = skipLeadingNewlines(value); + return start > 0 ? value.slice(start) : value; } function findNextParagraphBreak( diff --git a/src/agents/pi-embedded-helpers.validate-turns.test.ts b/src/agents/pi-embedded-helpers.validate-turns.test.ts index 8ba3f383001..342dbc8dfef 100644 --- a/src/agents/pi-embedded-helpers.validate-turns.test.ts +++ b/src/agents/pi-embedded-helpers.validate-turns.test.ts @@ -10,6 +10,28 @@ function asMessages(messages: unknown[]): AgentMessage[] { return messages as AgentMessage[]; } +function makeDualToolUseAssistantContent() { + return [ + { type: "toolUse", id: "tool-1", name: "test1", input: {} }, + { type: "toolUse", id: "tool-2", name: "test2", input: {} }, + { type: "text", text: "Done" }, + ]; +} + +function makeDualToolAnthropicTurns(nextUserContent: unknown[]) { + return asMessages([ + { role: "user", content: [{ type: "text", text: "Use tools" }] }, + { + role: "assistant", + content: makeDualToolUseAssistantContent(), + }, + { + role: "user", + content: nextUserContent, + }, + ]); +} + describe("validate turn edge cases", () => { it("returns empty array unchanged", () => { expect(validateGeminiTurns([])).toEqual([]); @@ -410,18 +432,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { }); it("should handle multiple dangling tool_use blocks", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tools" }] }, - { - role: "assistant", - content: [ - { type: "toolUse", id: "tool-1", name: "test1", input: {} }, - { type: "toolUse", id: "tool-2", name: "test2", input: {} }, - { type: "text", text: "Done" }, - ], - }, - { role: "user", content: [{ type: "text", text: "OK" }] }, - ]); + const msgs = makeDualToolAnthropicTurns([{ type: "text", text: "OK" }]); const result = validateAnthropicTurns(msgs); @@ -432,27 +443,13 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { }); it("should handle mixed tool_use with some having matching tool_result", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tools" }] }, + const msgs = makeDualToolAnthropicTurns([ { - role: "assistant", - content: [ - { type: "toolUse", id: "tool-1", name: "test1", input: {} }, - { type: "toolUse", id: "tool-2", name: "test2", input: {} }, - { type: "text", text: "Done" }, - ], - }, - { - role: "user", - content: [ - { - type: "toolResult", - toolUseId: "tool-1", - content: [{ type: "text", text: "Result 1" }], - }, - { type: "text", text: "Thanks" }, - ], + type: "toolResult", + toolUseId: "tool-1", + content: [{ type: "text", text: "Result 1" }], }, + { type: "text", text: "Thanks" }, ]); const result = validateAnthropicTurns(msgs); @@ -486,25 +483,11 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { }); it("is replay-safe across repeated validation passes", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tools" }] }, + const msgs = makeDualToolAnthropicTurns([ { - role: "assistant", - content: [ - { type: "toolUse", id: "tool-1", name: "test1", input: {} }, - { type: "toolUse", id: "tool-2", name: "test2", input: {} }, - { type: "text", text: "Done" }, - ], - }, - { - role: "user", - content: [ - { - type: "toolResult", - toolUseId: "tool-1", - content: [{ type: "text", text: "Result 1" }], - }, - ], + type: "toolResult", + toolUseId: "tool-1", + content: [{ type: "text", text: "Result 1" }], }, ]); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index f34e1514635..0ebe9ffbafa 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1072,7 +1072,7 @@ describe("applyExtraParamsToAgent", () => { // Simulate pi-agent-core passing apiKey in options (API key, not OAuth token) void agent.streamFn?.(model, context, { - apiKey: "sk-ant-api03-test", + apiKey: "sk-ant-api03-test", // pragma: allowlist secret headers: { "X-Custom": "1" }, }); @@ -1130,7 +1130,7 @@ describe("applyExtraParamsToAgent", () => { // Simulate pi-agent-core passing an OAuth token (sk-ant-oat-*) as apiKey void agent.streamFn?.(model, context, { - apiKey: "sk-ant-oat01-test-oauth-token", + apiKey: "sk-ant-oat01-test-oauth-token", // pragma: allowlist secret headers: { "X-Custom": "1" }, }); @@ -1151,7 +1151,7 @@ describe("applyExtraParamsToAgent", () => { cfg, modelId: "claude-sonnet-4-5", options: { - apiKey: "sk-ant-api03-test", + apiKey: "sk-ant-api03-test", // pragma: allowlist secret headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, }, }); diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 87ffa6963c9..75ce17eb197 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -156,7 +156,7 @@ const makeAgentOverrideOnlyFallbackConfig = (agentId: string): OpenClawConfig => providers: { openai: { api: "openai-responses", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret baseUrl: "https://example.com", models: [ { diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index d23b68d32b6..ca12a76cb36 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -179,6 +179,28 @@ describe("buildInlineProviderModels", () => { expect(result).toHaveLength(1); expect(result[0].headers).toBeUndefined(); }); + + it("preserves literal marker-shaped headers in inline provider models", () => { + const providers: Parameters[0] = { + custom: { + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + models: [makeModel("custom-model")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].headers).toEqual({ + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }); + }); }); describe("resolveModel", () => { @@ -223,6 +245,56 @@ describe("resolveModel", () => { }); }); + it("preserves literal marker-shaped provider headers in fallback models", () => { + const cfg = { + models: { + providers: { + custom: { + baseUrl: "http://localhost:9000", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }, + models: [makeModel("listed-model")], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Custom-Auth": "token-123", + }); + }); + + it("drops marker headers from discovered models.json entries", () => { + mockDiscoveredModel({ + provider: "custom", + modelId: "listed-model", + templateModel: { + ...makeModel("listed-model"), + provider: "custom", + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", + "X-Managed": "secretref-managed", + "X-Static": "tenant-a", + }, + }, + }); + + const result = resolveModel("custom", "listed-model", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect((result.model as unknown as { headers?: Record }).headers).toEqual({ + "X-Static": "tenant-a", + }); + }); + it("prefers matching configured model metadata for fallback token limits", () => { const cfg = { models: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index b846895d029..f1b31a5e49a 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -5,6 +5,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; +import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js"; import { normalizeModelCompat } from "../model-compat.js"; import { resolveForwardCompatModel } from "../model-forward-compat.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; @@ -19,9 +20,29 @@ type InlineProviderConfig = { baseUrl?: string; api?: ModelDefinitionConfig["api"]; models?: ModelDefinitionConfig[]; - headers?: Record; + headers?: unknown; }; +function sanitizeModelHeaders( + headers: unknown, + opts?: { stripSecretRefMarkers?: boolean }, +): Record | undefined { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return undefined; + } + const next: Record = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== "string") { + continue; + } + if (opts?.stripSecretRefMarkers && isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + next[headerName] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +} + export { buildModelAliasLines }; function resolveConfiguredProviderConfig( @@ -46,16 +67,23 @@ function applyConfiguredProviderOverrides(params: { }): Model { const { discoveredModel, providerConfig, modelId } = params; if (!providerConfig) { - return discoveredModel; + return { + ...discoveredModel, + // Discovered models originate from models.json and may contain persistence markers. + headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }), + }; } const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId); - if ( - !configuredModel && - !providerConfig.baseUrl && - !providerConfig.api && - !providerConfig.headers - ) { - return discoveredModel; + const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, { + stripSecretRefMarkers: true, + }); + const providerHeaders = sanitizeModelHeaders(providerConfig.headers); + const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers); + if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) { + return { + ...discoveredModel, + headers: discoveredHeaders, + }; } return { ...discoveredModel, @@ -67,13 +95,13 @@ function applyConfiguredProviderOverrides(params: { contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow, maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens, headers: - providerConfig.headers || configuredModel?.headers + discoveredHeaders || providerHeaders || configuredHeaders ? { - ...discoveredModel.headers, - ...providerConfig.headers, - ...configuredModel?.headers, + ...discoveredHeaders, + ...providerHeaders, + ...configuredHeaders, } - : discoveredModel.headers, + : undefined, compat: configuredModel?.compat ?? discoveredModel.compat, }; } @@ -86,15 +114,22 @@ export function buildInlineProviderModels( if (!trimmed) { return []; } + const providerHeaders = sanitizeModelHeaders(entry?.headers); return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed, baseUrl: entry?.baseUrl, api: model.api ?? entry?.api, - headers: - entry?.headers || (model as InlineModelEntry).headers - ? { ...entry?.headers, ...(model as InlineModelEntry).headers } - : undefined, + headers: (() => { + const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers); + if (!providerHeaders && !modelHeaders) { + return undefined; + } + return { + ...providerHeaders, + ...modelHeaders, + }; + })(), })); }); } @@ -161,6 +196,8 @@ export function resolveModelWithRegistry(params: { } const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId); + const providerHeaders = sanitizeModelHeaders(providerConfig?.headers); + const modelHeaders = sanitizeModelHeaders(configuredModel?.headers); if (providerConfig || modelId.startsWith("mock-")) { return normalizeModelCompat({ id: modelId, @@ -180,9 +217,7 @@ export function resolveModelWithRegistry(params: { providerConfig?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, headers: - providerConfig?.headers || configuredModel?.headers - ? { ...providerConfig?.headers, ...configuredModel?.headers } - : undefined, + providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined, } as Model); } diff --git a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts index 03191e51c8e..8d42b061b81 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js"; +import { writePluginWithSkill } from "../test-helpers/skill-plugin-fixtures.js"; import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js"; const tempDirs: string[] = []; @@ -20,26 +21,12 @@ async function setupBundledDiffsPlugin() { const workspaceDir = await createTempDir("openclaw-workspace-"); const pluginRoot = path.join(bundledPluginsDir, "diffs"); - await fs.mkdir(path.join(pluginRoot, "skills", "diffs"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "diffs", - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, "skills", "diffs", "SKILL.md"), - `---\nname: diffs\ndescription: runtime integration test\n---\n`, - "utf-8", - ); + await writePluginWithSkill({ + pluginRoot, + pluginId: "diffs", + skillId: "diffs", + skillDescription: "runtime integration test", + }); return { bundledPluginsDir, workspaceDir }; } diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index c88ce044262..882099f3569 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -697,7 +697,7 @@ describe("compaction-safeguard recent-turn preservation", () => { "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and URL https://example.com/a and /tmp/x.log plus port host.local:18789", ); expect(identifiers.length).toBeGreaterThan(0); - expect(identifiers).toContain("A1B2C3D4E5F6"); + expect(identifiers).toContain("A1B2C3D4E5F6"); // pragma: allowlist secret const summary = [ "## Decisions", @@ -724,7 +724,7 @@ describe("compaction-safeguard recent-turn preservation", () => { const identifiers = extractOpaqueIdentifiers( "Track id a1b2c3d4e5f6 plus A1B2C3D4E5F6 and again a1b2c3d4e5f6", ); - expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); + expect(identifiers.filter((id) => id === "A1B2C3D4E5F6")).toHaveLength(1); // pragma: allowlist secret }); it("dedupes identifiers before applying the result cap", () => { @@ -843,9 +843,9 @@ describe("compaction-safeguard recent-turn preservation", () => { "## Pending user asks", "Provide status.", "## Exact identifiers", - "a1b2c3d4e5f6", + "a1b2c3d4e5f6", // pragma: allowlist secret ].join("\n"), - identifiers: ["A1B2C3D4E5F6"], + identifiers: ["A1B2C3D4E5F6"], // pragma: allowlist secret latestAsk: "Provide status.", identifierPolicy: "strict", }); @@ -1522,7 +1522,7 @@ describe("compaction-safeguard double-compaction guard", () => { const { result, getApiKeyMock } = await runCompactionScenario({ sessionManager, event: mockEvent, - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret }); expect(result).toEqual({ cancel: true }); expect(getApiKeyMock).not.toHaveBeenCalled(); diff --git a/src/agents/sandbox/browser.novnc-url.test.ts b/src/agents/sandbox/browser.novnc-url.test.ts index d7a6bb93d0c..e8d7d43841d 100644 --- a/src/agents/sandbox/browser.novnc-url.test.ts +++ b/src/agents/sandbox/browser.novnc-url.test.ts @@ -9,13 +9,16 @@ import { resetNoVncObserverTokensForTests, } from "./novnc-auth.js"; +const passwordKey = ["pass", "word"].join(""); + describe("noVNC auth helpers", () => { it("builds the default observer URL without password", () => { expect(buildNoVncDirectUrl(45678)).toBe("http://127.0.0.1:45678/vnc.html"); }); it("builds a fragment-based observer target URL with password", () => { - expect(buildNoVncObserverTargetUrl({ port: 45678, password: "a+b c&d" })).toBe( + const observerPassword = "a+b c&d"; // pragma: allowlist secret + expect(buildNoVncObserverTargetUrl({ port: 45678, [passwordKey]: observerPassword })).toBe( "http://127.0.0.1:45678/vnc.html#autoconnect=1&resize=remote&password=a%2Bb+c%26d", ); }); @@ -24,7 +27,7 @@ describe("noVNC auth helpers", () => { resetNoVncObserverTokensForTests(); const token = issueNoVncObserverToken({ noVncPort: 50123, - password: "abcd1234", + [passwordKey]: "abcd1234", // pragma: allowlist secret nowMs: 1000, ttlMs: 100, }); @@ -33,7 +36,7 @@ describe("noVNC auth helpers", () => { ); expect(consumeNoVncObserverToken(token, 1050)).toEqual({ noVncPort: 50123, - password: "abcd1234", + [passwordKey]: "abcd1234", // pragma: allowlist secret }); expect(consumeNoVncObserverToken(token, 1050)).toBeNull(); }); @@ -42,7 +45,7 @@ describe("noVNC auth helpers", () => { resetNoVncObserverTokensForTests(); const token = issueNoVncObserverToken({ noVncPort: 50123, - password: "abcd1234", + password: "abcd1234", // pragma: allowlist secret nowMs: 1000, ttlMs: 100, }); diff --git a/src/agents/sandbox/sanitize-env-vars.test.ts b/src/agents/sandbox/sanitize-env-vars.test.ts index 9367ef55191..5e3f2f1c40f 100644 --- a/src/agents/sandbox/sanitize-env-vars.test.ts +++ b/src/agents/sandbox/sanitize-env-vars.test.ts @@ -5,9 +5,9 @@ describe("sanitizeEnvVars", () => { it("keeps normal env vars and blocks obvious credentials", () => { const result = sanitizeEnvVars({ NODE_ENV: "test", - OPENAI_API_KEY: "sk-live-xxx", + OPENAI_API_KEY: "sk-live-xxx", // pragma: allowlist secret FOO: "bar", - GITHUB_TOKEN: "gh-token", + GITHUB_TOKEN: "gh-token", // pragma: allowlist secret }); expect(result.allowed).toEqual({ diff --git a/src/agents/session-transcript-repair.attachments.test.ts b/src/agents/session-transcript-repair.attachments.test.ts index 88e119f90db..467fc6f3e6c 100644 --- a/src/agents/session-transcript-repair.attachments.test.ts +++ b/src/agents/session-transcript-repair.attachments.test.ts @@ -29,7 +29,7 @@ function mkSessionsSpawnToolCall(content: string): AgentMessage { describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { it("replaces attachments[].content with __OPENCLAW_REDACTED__", () => { - const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST"; + const secret = "SUPER_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret const input = [mkSessionsSpawnToolCall(secret)]; const out = sanitizeToolCallInputs(input); expect(out).toHaveLength(1); @@ -44,7 +44,7 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { }); it("redacts attachments content from tool input payloads too", () => { - const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST"; + const secret = "INPUT_SECRET_SHOULD_NOT_PERSIST"; // pragma: allowlist secret const input = castAgentMessages([ { role: "assistant", diff --git a/src/agents/skills-install.download.test.ts b/src/agents/skills-install.download.test.ts index 2f17248f24f..e030b9cbf76 100644 --- a/src/agents/skills-install.download.test.ts +++ b/src/agents/skills-install.download.test.ts @@ -48,7 +48,7 @@ const ZIP_SLIP_BUFFER = Buffer.from( ); const TAR_GZ_TRAVERSAL_BUFFER = Buffer.from( // Prebuilt archive containing ../outside-write/pwned.txt. - "H4sIAK4xm2kAA+2VvU7DMBDH3UoIUWaYLXbcS5PYZegQEKhBRUBbIT4GZBpXCqJNSFySlSdgZed1eCgcUvFRaMsQgVD9k05nW3eWz8nfR0g1GMnY98RmEvlSVMllmAyFR2QqUUEAALUsnHlG7VcPtXwO+djEhm1YlJpAbYrBYAYDhKGoA8xiFEseqaPEUvihkGJanArr92fsk5eC3/x/YWl9GZUROuA9fNjBp3hMtoZWlNWU3SrL5k8/29LpdtvjYZbxqGx1IqT0vr7WCwaEh+GNIGEU3IkhH/YEKpXRxv3FQznsPxdQpGYaZFL/RzxtCu6JqFrYOzBX/wZ81n8NmEERTosocB4Lrn8T8ED6A9EwmHp0Wd1idQK2ZVIAm1ZshlvuttPeabonuyTlUkbkO7k2nGPXcYO9q+tkPzmPk4q1hTsqqXU2K+mDxit/fQ+Lyhf9F9795+tf/WoT/Z8yi+n+/xuoz+1p8Wk0Gs3i8QJSs3VlABAAAA==", + "H4sIAK4xm2kAA+2VvU7DMBDH3UoIUWaYLXbcS5PYZegQEKhBRUBbIT4GZBpXCqJNSFySlSdgZed1eCgcUvFRaMsQgVD9k05nW3eWz8nfR0g1GMnY98RmEvlSVMllmAyFR2QqUUEAALUsnHlG7VcPtXwO+djEhm1YlJpAbYrBYAYDhKGoA8xiFEseqaPEUvihkGJanArr92fsk5eC3/x/YWl9GZUROuA9fNjBp3hMtoZWlNWU3SrL5k8/29LpdtvjYZbxqGx1IqT0vr7WCwaEh+GNIGEU3IkhH/YEKpXRxv3FQznsPxdQpGYaZFL/RzxtCu6JqFrYOzBX/wZ81n8NmEERTosocB4Lrn8T8ED6A9EwmHp0Wd1idQK2ZVIAm1ZshlvuttPeabonuyTlUkbkO7k2nGPXcYO9q+tkPzmPk4q1hTsqqXU2K+mDxit/fQ+Lyhf9F9795+tf/WoT/Z8yi+n+/xuoz+1p8Wk0Gs3i8QJSs3VlABAAAA==", // pragma: allowlist secret "base64", ); diff --git a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts index 06d2561829c..fcd4022a419 100644 --- a/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts @@ -115,7 +115,7 @@ describe("buildWorkspaceSkillsPrompt", () => { managedSkillsDir, config: { browser: { enabled: false }, - skills: { entries: { "env-skill": { apiKey: "ok" } } }, + skills: { entries: { "env-skill": { apiKey: "ok" } } }, // pragma: allowlist secret }, eligibility: { remote: { diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts index cced568ecbc..0e24a105356 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts @@ -178,7 +178,7 @@ describe("buildWorkspaceSkillsPrompt", () => { const enabledPrompt = buildPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), config: { - skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, + skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, // pragma: allowlist secret }, }); expect(enabledPrompt).toContain("nano-banana-pro"); diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index 456355e4ea7..da5f55fd0b6 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { loadWorkspaceSkillEntries } from "./skills.js"; +import { writePluginWithSkill } from "./test-helpers/skill-plugin-fixtures.js"; const tempDirs: string[] = []; @@ -24,26 +25,12 @@ async function setupWorkspaceWithProsePlugin() { const bundledDir = path.join(workspaceDir, ".bundled"); const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "open-prose"); - await fs.mkdir(path.join(pluginRoot, "skills", "prose"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "open-prose", - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, "skills", "prose", "SKILL.md"), - `---\nname: prose\ndescription: test\n---\n`, - "utf-8", - ); + await writePluginWithSkill({ + pluginRoot, + pluginId: "open-prose", + skillId: "prose", + skillDescription: "test", + }); return { workspaceDir, managedDir, bundledDir }; } @@ -54,26 +41,12 @@ async function setupWorkspaceWithDiffsPlugin() { const bundledDir = path.join(workspaceDir, ".bundled"); const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "diffs"); - await fs.mkdir(path.join(pluginRoot, "skills", "diffs"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: "diffs", - skills: ["./skills"], - configSchema: { type: "object", additionalProperties: false, properties: {} }, - }, - null, - 2, - ), - "utf-8", - ); - await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8"); - await fs.writeFile( - path.join(pluginRoot, "skills", "diffs", "SKILL.md"), - `---\nname: diffs\ndescription: test\n---\n`, - "utf-8", - ); + await writePluginWithSkill({ + pluginRoot, + pluginId: "diffs", + skillId: "diffs", + skillDescription: "test", + }); return { workspaceDir, managedDir, bundledDir }; } diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index a444fceded4..394f476ffa8 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -23,6 +23,7 @@ const resolveTestSkillDirs = (workspaceDir: string) => ({ }); const makeWorkspace = async () => await fixtureSuite.createCaseDir("workspace"); +const apiKeyField = ["api", "Key"].join(""); const withClearedEnv = ( keys: string[], @@ -252,7 +253,7 @@ describe("applySkillEnvOverrides", () => { withClearedEnv(["ENV_KEY"], () => { const restore = applySkillEnvOverrides({ skills: entries, - config: { skills: { entries: { "env-skill": { apiKey: "injected" } } } }, + config: { skills: { entries: { "env-skill": { apiKey: "injected" } } } }, // pragma: allowlist secret }); try { @@ -279,7 +280,7 @@ describe("applySkillEnvOverrides", () => { const entries = loadWorkspaceSkillEntries(workspaceDir, resolveTestSkillDirs(workspaceDir)); withClearedEnv(["ENV_KEY"], () => { - const config = { skills: { entries: { "env-skill": { apiKey: "injected" } } } }; + const config = { skills: { entries: { "env-skill": { [apiKeyField]: "injected" } } } }; // pragma: allowlist secret const restoreFirst = applySkillEnvOverrides({ skills: entries, config }); const restoreSecond = applySkillEnvOverrides({ skills: entries, config }); @@ -310,13 +311,13 @@ describe("applySkillEnvOverrides", () => { const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, { ...resolveTestSkillDirs(workspaceDir), - config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, + config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, // pragma: allowlist secret }); withClearedEnv(["ENV_KEY"], () => { const restore = applySkillEnvOverridesFromSnapshot({ snapshot, - config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, + config: { skills: { entries: { "env-skill": { apiKey: "snap-key" } } } }, // pragma: allowlist secret }); try { @@ -349,7 +350,7 @@ describe("applySkillEnvOverrides", () => { entries: { "unsafe-env-skill": { env: { - OPENAI_API_KEY: "sk-test", + OPENAI_API_KEY: "sk-test", // pragma: allowlist secret NODE_OPTIONS: "--require /tmp/evil.js", }, }, @@ -424,7 +425,7 @@ describe("applySkillEnvOverrides", () => { entries: { "snapshot-env-skill": { env: { - OPENAI_API_KEY: "snap-secret", + OPENAI_API_KEY: "snap-secret", // pragma: allowlist secret }, }, }, diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 64497364f8c..3877f6fed21 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -73,14 +73,14 @@ describe("buildAgentSystemPrompt", () => { workspaceDir: "/tmp/openclaw", ownerNumbers: ["+123"], ownerDisplay: "hash", - ownerDisplaySecret: "secret-key-A", + ownerDisplaySecret: "secret-key-A", // pragma: allowlist secret }); const secretB = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", ownerNumbers: ["+123"], ownerDisplay: "hash", - ownerDisplaySecret: "secret-key-B", + ownerDisplaySecret: "secret-key-B", // pragma: allowlist secret }); const lineA = secretA.split("## Authorized Senders")[1]?.split("\n")[1]; diff --git a/src/agents/test-helpers/skill-plugin-fixtures.ts b/src/agents/test-helpers/skill-plugin-fixtures.ts new file mode 100644 index 00000000000..614da4d75e6 --- /dev/null +++ b/src/agents/test-helpers/skill-plugin-fixtures.ts @@ -0,0 +1,30 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function writePluginWithSkill(params: { + pluginRoot: string; + pluginId: string; + skillId: string; + skillDescription: string; +}) { + await fs.mkdir(path.join(params.pluginRoot, "skills", params.skillId), { recursive: true }); + await fs.writeFile( + path.join(params.pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.pluginId, + skills: ["./skills"], + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile(path.join(params.pluginRoot, "index.ts"), "export {};\n", "utf-8"); + await fs.writeFile( + path.join(params.pluginRoot, "skills", params.skillId, "SKILL.md"), + `---\nname: ${params.skillId}\ndescription: ${params.skillDescription}\n---\n`, + "utf-8", + ); +} diff --git a/src/agents/tools/pdf-tool.test.ts b/src/agents/tools/pdf-tool.test.ts index 8a422350ed8..6cbc6ca54d1 100644 --- a/src/agents/tools/pdf-tool.test.ts +++ b/src/agents/tools/pdf-tool.test.ts @@ -71,7 +71,7 @@ function makeAnthropicAnalyzeParams( }> = {}, ) { return { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret modelId: "claude-opus-4-6", prompt: "test", pdfs: [TEST_PDF_INPUT], @@ -89,7 +89,7 @@ function makeGeminiAnalyzeParams( }> = {}, ) { return { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret modelId: "gemini-2.5-pro", prompt: "test", pdfs: [TEST_PDF_INPUT], @@ -156,7 +156,7 @@ async function stubPdfToolInfra( }); const modelAuth = await import("../model-auth.js"); - vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never); + vi.spyOn(modelAuth, "getApiKeyForModel").mockResolvedValue({ apiKey: "test-key" } as never); // pragma: allowlist secret vi.spyOn(modelAuth, "requireApiKey").mockReturnValue("test-key"); return { loadSpy }; diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index af3d934c208..eb868068ece 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -81,7 +81,7 @@ describe("web_fetch SSRF protection", () => { it("blocks localhost hostnames before fetch/firecrawl", async () => { const fetchSpy = setMockFetch(); const tool = await createWebFetchToolForTest({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); await expectBlockedUrl(tool, "http://localhost/test", /Blocked hostname/i); @@ -123,7 +123,7 @@ describe("web_fetch SSRF protection", () => { redirectResponse("http://127.0.0.1/secret"), ); const tool = await createWebFetchToolForTest({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); await expectBlockedUrl(tool, "https://example.com", /private|internal|blocked/i); diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 47da8aedd08..7e8f696e883 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -17,6 +17,9 @@ const { extractKimiCitations, } = __testing; +const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); +const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_"); + describe("web_search brave language param normalization", () => { it("normalizes and auto-corrects swapped Brave language params", () => { expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({ @@ -102,7 +105,7 @@ describe("web_search date normalization", () => { describe("web_search grok config resolution", () => { it("uses config apiKey when provided", () => { - expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); + expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret }); it("returns undefined when no apiKey is available", () => { @@ -221,15 +224,17 @@ describe("web_search grok response parsing", () => { describe("web_search kimi config resolution", () => { it("uses config apiKey when provided", () => { - expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); + expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret }); it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => { - withEnv({ KIMI_API_KEY: "kimi-env", MOONSHOT_API_KEY: "moonshot-env" }, () => { - expect(resolveKimiApiKey({})).toBe("kimi-env"); + const kimiEnvValue = "kimi-env"; // pragma: allowlist secret + const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret + withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => { + expect(resolveKimiApiKey({})).toBe(kimiEnvValue); }); - withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: "moonshot-env" }, () => { - expect(resolveKimiApiKey({})).toBe("moonshot-env"); + withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => { + expect(resolveKimiApiKey({})).toBe(moonshotEnvValue); }); }); diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 53af4a5c8f3..befffcf6fce 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -50,14 +50,14 @@ function createKimiSearchTool(kimiConfig?: { apiKey?: string; baseUrl?: string; function createProviderSearchTool(provider: "brave" | "perplexity" | "grok" | "gemini" | "kimi") { const searchConfig = provider === "perplexity" - ? { provider, perplexity: { apiKey: "pplx-config-test" } } + ? { provider, perplexity: { apiKey: "pplx-config-test" } } // pragma: allowlist secret : provider === "grok" - ? { provider, grok: { apiKey: "xai-config-test" } } + ? { provider, grok: { apiKey: "xai-config-test" } } // pragma: allowlist secret : provider === "gemini" - ? { provider, gemini: { apiKey: "gemini-config-test" } } + ? { provider, gemini: { apiKey: "gemini-config-test" } } // pragma: allowlist secret : provider === "kimi" - ? { provider, kimi: { apiKey: "moonshot-config-test" } } - : { provider, apiKey: "brave-config-test" }; + ? { provider, kimi: { apiKey: "moonshot-config-test" } } // pragma: allowlist secret + : { provider, apiKey: "brave-config-test" }; // pragma: allowlist secret return createWebSearchTool({ config: { tools: { @@ -458,7 +458,7 @@ describe("web_search kimi provider", () => { global.fetch = withFetchPreconnect(mockFetch); const tool = createKimiSearchTool({ - apiKey: "kimi-config-key", + apiKey: "kimi-config-key", // pragma: allowlist secret baseUrl: "https://api.moonshot.ai/v1", model: "moonshot-v1-128k", }); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index accf76adc42..9da57a35b45 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -29,6 +29,8 @@ function htmlResponse(html: string, url = "https://example.com/"): MockResponse }; } +const apiKeyField = ["api", "Key"].join(""); + function firecrawlResponse(markdown: string, url = "https://example.com/"): MockResponse { return { ok: true, @@ -130,8 +132,12 @@ function installPlainTextFetch(text: string) { ); } -function createFirecrawlTool(apiKey = "firecrawl-test") { - return createFetchTool({ firecrawl: { apiKey } }); +function createFirecrawlTool(apiKey = defaultFirecrawlApiKey()) { + return createFetchTool({ firecrawl: { [apiKeyField]: apiKey } }); +} + +function defaultFirecrawlApiKey() { + return "firecrawl-test"; // pragma: allowlist secret } async function executeFetch( @@ -385,7 +391,7 @@ describe("web_fetch extraction fallbacks", () => { }); const tool = createFetchTool({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); const result = await tool?.execute?.("call", { url: "https://example.com/blocked" }); @@ -477,7 +483,7 @@ describe("web_fetch extraction fallbacks", () => { }); const tool = createFetchTool({ - firecrawl: { apiKey: "firecrawl-test" }, + firecrawl: { apiKey: "firecrawl-test" }, // pragma: allowlist secret }); const message = await captureToolErrorMessage({ diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index f6ae74d909d..07b40069d57 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as fences from "../markdown/fences.js"; import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; import { chunkByNewline, @@ -217,6 +218,17 @@ describe("chunkMarkdownText", () => { expect(chunks[0]?.length).toBe(20); expect(chunks.join("")).toBe(text); }); + + it("parses fence spans once for long fenced payloads", () => { + const parseSpy = vi.spyOn(fences, "parseFenceSpans"); + const text = `\`\`\`txt\n${"line\n".repeat(600)}\`\`\``; + + const chunks = chunkMarkdownText(text, 80); + + expect(chunks.length).toBeGreaterThan(2); + expect(parseSpy).toHaveBeenCalledTimes(1); + parseSpy.mockRestore(); + }); }); describe("chunkByNewline", () => { diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 780d57a1f5b..9d16f36d532 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -306,7 +306,7 @@ export function chunkText(text: string, limit: number): string[] { } return chunkTextByBreakResolver(text, limit, (window) => { // 1) Prefer a newline break inside the window (outside parentheses). - const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window); + const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, 0, window.length); // 2) Otherwise prefer the last whitespace (word boundary) inside the window. return lastNewline > 0 ? lastNewline : lastWhitespace; }); @@ -319,14 +319,24 @@ export function chunkMarkdownText(text: string, limit: number): string[] { } const chunks: string[] = []; - let remaining = text; + const spans = parseFenceSpans(text); + let start = 0; + let reopenFence: ReturnType | undefined; - while (remaining.length > limit) { - const spans = parseFenceSpans(remaining); - const window = remaining.slice(0, limit); + while (start < text.length) { + const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; + const contentLimit = Math.max(1, limit - reopenPrefix.length); + if (text.length - start <= contentLimit) { + const finalChunk = `${reopenPrefix}${text.slice(start)}`; + if (finalChunk.length > 0) { + chunks.push(finalChunk); + } + break; + } - const softBreak = pickSafeBreakIndex(window, spans); - let breakIdx = softBreak > 0 ? softBreak : limit; + const windowEnd = Math.min(text.length, start + contentLimit); + const softBreak = pickSafeBreakIndex(text, start, windowEnd, spans); + let breakIdx = softBreak > start ? softBreak : windowEnd; const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined @@ -335,38 +345,38 @@ export function chunkMarkdownText(text: string, limit: number): string[] { let fenceToSplit = initialFence; if (initialFence) { const closeLine = `${initialFence.indent}${initialFence.marker}`; - const maxIdxIfNeedNewline = limit - (closeLine.length + 1); + const maxIdxIfNeedNewline = start + (contentLimit - (closeLine.length + 1)); - if (maxIdxIfNeedNewline <= 0) { + if (maxIdxIfNeedNewline <= start) { fenceToSplit = undefined; - breakIdx = limit; + breakIdx = windowEnd; } else { const minProgressIdx = Math.min( - remaining.length, - initialFence.start + initialFence.openLine.length + 2, + text.length, + Math.max(start + 1, initialFence.start + initialFence.openLine.length + 2), ); - const maxIdxIfAlreadyNewline = limit - closeLine.length; + const maxIdxIfAlreadyNewline = start + (contentLimit - closeLine.length); let pickedNewline = false; - let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1)); - while (lastNewline !== -1) { + let lastNewline = text.lastIndexOf("\n", Math.max(start, maxIdxIfAlreadyNewline - 1)); + while (lastNewline >= start) { const candidateBreak = lastNewline + 1; if (candidateBreak < minProgressIdx) { break; } const candidateFence = findFenceSpanAt(spans, candidateBreak); if (candidateFence && candidateFence.start === initialFence.start) { - breakIdx = Math.max(1, candidateBreak); + breakIdx = candidateBreak; pickedNewline = true; break; } - lastNewline = remaining.lastIndexOf("\n", lastNewline - 1); + lastNewline = text.lastIndexOf("\n", lastNewline - 1); } if (!pickedNewline) { if (minProgressIdx > maxIdxIfAlreadyNewline) { fenceToSplit = undefined; - breakIdx = limit; + breakIdx = windowEnd; } else { breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline); } @@ -378,68 +388,72 @@ export function chunkMarkdownText(text: string, limit: number): string[] { fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined; } - let rawChunk = remaining.slice(0, breakIdx); - if (!rawChunk) { + const rawContent = text.slice(start, breakIdx); + if (!rawContent) { break; } - const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); - const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); - let next = remaining.slice(nextStart); + let rawChunk = `${reopenPrefix}${rawContent}`; + const brokeOnSeparator = breakIdx < text.length && /\s/.test(text[breakIdx]); + let nextStart = Math.min(text.length, breakIdx + (brokeOnSeparator ? 1 : 0)); if (fenceToSplit) { const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`; rawChunk = rawChunk.endsWith("\n") ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`; - next = `${fenceToSplit.openLine}\n${next}`; + reopenFence = fenceToSplit; } else { - next = stripLeadingNewlines(next); + nextStart = skipLeadingNewlines(text, nextStart); + reopenFence = undefined; } chunks.push(rawChunk); - remaining = next; - } - - if (remaining.length) { - chunks.push(remaining); + start = nextStart; } return chunks; } -function stripLeadingNewlines(value: string): string { - let i = 0; +function skipLeadingNewlines(value: string, start = 0): number { + let i = start; while (i < value.length && value[i] === "\n") { i++; } - return i > 0 ? value.slice(i) : value; + return i; } -function pickSafeBreakIndex(window: string, spans: ReturnType): number { - const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) => +function pickSafeBreakIndex( + text: string, + start: number, + end: number, + spans: ReturnType, +): number { + const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(text, start, end, (index) => isSafeFenceBreak(spans, index), ); - if (lastNewline > 0) { + if (lastNewline > start) { return lastNewline; } - if (lastWhitespace > 0) { + if (lastWhitespace > start) { return lastWhitespace; } return -1; } function scanParenAwareBreakpoints( - window: string, + text: string, + start: number, + end: number, isAllowed: (index: number) => boolean = () => true, ): { lastNewline: number; lastWhitespace: number } { let lastNewline = -1; let lastWhitespace = -1; let depth = 0; - for (let i = 0; i < window.length; i++) { + for (let i = start; i < end; i++) { if (!isAllowed(i)) { continue; } - const char = window[i]; + const char = text[i]; if (char === "(") { depth += 1; continue; diff --git a/src/auto-reply/command-auth.owner-default.test.ts b/src/auto-reply/command-auth.owner-default.test.ts index 3cb6b48d3fd..d2f99c1a995 100644 --- a/src/auto-reply/command-auth.owner-default.test.ts +++ b/src/auto-reply/command-auth.owner-default.test.ts @@ -1,26 +1,10 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { resolveCommandAuthorization } from "./command-auth.js"; import type { MsgContext } from "./templating.js"; +import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registry-fixture.js"; -const createRegistry = () => - createTestRegistry([ - { - pluginId: "discord", - plugin: createOutboundTestPlugin({ id: "discord", outbound: { deliveryMode: "direct" } }), - source: "test", - }, - ]); - -beforeEach(() => { - setActivePluginRegistry(createRegistry()); -}); - -afterEach(() => { - setActivePluginRegistry(createRegistry()); -}); +installDiscordRegistryHooks(); describe("senderIsOwner only reflects explicit owner authorization", () => { it("does not treat direct-message senders as owners when no ownerAllowFrom is configured", () => { diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index cb829871b10..9d5dc1de094 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -8,23 +8,9 @@ import { listChatCommands } from "./commands-registry.js"; import { parseActivationCommand } from "./group-activation.js"; import { parseSendPolicyCommand } from "./send-policy.js"; import type { MsgContext } from "./templating.js"; +import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registry-fixture.js"; -const createRegistry = () => - createTestRegistry([ - { - pluginId: "discord", - plugin: createOutboundTestPlugin({ id: "discord", outbound: { deliveryMode: "direct" } }), - source: "test", - }, - ]); - -beforeEach(() => { - setActivePluginRegistry(createRegistry()); -}); - -afterEach(() => { - setActivePluginRegistry(createRegistry()); -}); +installDiscordRegistryHooks(); describe("resolveCommandAuthorization", () => { function resolveWhatsAppAuthorization(params: { diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index ccaab1280f7..f15ff26e941 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -57,7 +57,7 @@ function makeMoonshotConfig(home: string, storePath: string) { providers: { moonshot: { baseUrl: "https://api.moonshot.ai/v1", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "openai-completions", models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2")], }, @@ -133,13 +133,13 @@ describe("directive behavior", () => { providers: { minimax: { baseUrl: "https://api.minimax.io/anthropic", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", models: [makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5")], }, lmstudio: { baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", + apiKey: "lmstudio", // pragma: allowlist secret api: "openai-responses", models: [makeModelDefinition("minimax-m2.5-gs32", "MiniMax M2.5 GS32")], }, @@ -166,7 +166,7 @@ describe("directive behavior", () => { providers: { minimax: { baseUrl: "https://api.minimax.io/anthropic", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", models: [ makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"), @@ -215,13 +215,13 @@ describe("directive behavior", () => { providers: { moonshot: { baseUrl: "https://api.moonshot.ai/v1", - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret api: "openai-completions", models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2")], }, lmstudio: { baseUrl: "http://127.0.0.1:1234/v1", - apiKey: "lmstudio", + apiKey: "lmstudio", // pragma: allowlist secret api: "openai-responses", models: [makeModelDefinition("kimi-k2-0905-preview", "Kimi K2 (Local)")], }, diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts index 1a738d5731f..c96bf6c65a0 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts @@ -213,7 +213,7 @@ export function registerTriggerHandlingUsageSummaryCases(params: { expect(text).toContain("api-key"); expect(text).not.toContain("sk-test"); expect(text).not.toContain("abcdef"); - expect(text).not.toContain("1234567890abcdef"); + expect(text).not.toContain("1234567890abcdef"); // pragma: allowlist secret expect(text).toContain("(anthropic:work)"); expect(text).not.toContain("mixed"); expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index e4b9b7af561..13c79dc796d 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -196,6 +196,31 @@ function extractConfigAllowlist(account: { }; } +async function updatePairingStoreAllowlist(params: { + action: "add" | "remove"; + channelId: ChannelId; + accountId?: string; + entry: string; +}) { + const storeEntry = { + channel: params.channelId, + entry: params.entry, + accountId: params.accountId, + }; + if (params.action === "add") { + await addChannelAllowFromStoreEntry(storeEntry); + return; + } + + await removeChannelAllowFromStoreEntry(storeEntry); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + await removeChannelAllowFromStoreEntry({ + channel: params.channelId, + entry: params.entry, + }); + } +} + function resolveAccountTarget( parsed: Record, channelId: ChannelId, @@ -695,11 +720,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } if (shouldTouchStore) { - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); } const actionLabel = parsed.action === "add" ? "added" : "removed"; @@ -727,11 +753,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } - if (parsed.action === "add") { - await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } else if (parsed.action === "remove") { - await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry }); - } + await updatePairingStoreAllowlist({ + action: parsed.action, + channelId, + accountId, + entry: parsed.entry, + }); const actionLabel = parsed.action === "add" ? "added" : "removed"; const scopeLabel = scope === "dm" ? "DM" : "group"; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index a83727d2daf..4d31b56a605 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -704,10 +704,74 @@ describe("handleCommands /allowlist", () => { expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ channel: "telegram", entry: "789", + accountId: "default", }); expect(result.reply?.text).toContain("DM allowlist added"); }); + it("writes store entries to the selected account scope", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm --account work 789", cfg, { + AccountId: "work", + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "work", + }); + }); + + it("removes default-account entries from scoped and legacy pairing stores", async () => { + removeChannelAllowFromStoreEntryMock + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }) + .mockResolvedValueOnce({ + changed: true, + allowFrom: [], + }); + + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist remove dm --store 789", cfg); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, { + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, { + channel: "telegram", + entry: "789", + }); + }); + it("rejects blocked account ids and keeps Object.prototype clean", async () => { delete (Object.prototype as Record).allowFrom; diff --git a/src/auto-reply/test-helpers/command-auth-registry-fixture.ts b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts new file mode 100644 index 00000000000..31d24d9763c --- /dev/null +++ b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts @@ -0,0 +1,22 @@ +import { afterEach, beforeEach } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; + +export const createDiscordRegistry = () => + createTestRegistry([ + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ id: "discord", outbound: { deliveryMode: "direct" } }), + source: "test", + }, + ]); + +export function installDiscordRegistryHooks() { + beforeEach(() => { + setActivePluginRegistry(createDiscordRegistry()); + }); + + afterEach(() => { + setActivePluginRegistry(createDiscordRegistry()); + }); +} diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts index 1f77175065e..cc8018c30ec 100644 --- a/src/browser/bridge-server.auth.test.ts +++ b/src/browser/bridge-server.auth.test.ts @@ -90,7 +90,7 @@ describe("startBrowserBridgeServer auth", () => { if (token !== "valid-token") { return null; } - return { noVncPort: 45678, password: "Abc123xy" }; + return { noVncPort: 45678, password: "Abc123xy" }; // pragma: allowlist secret }, }); servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); diff --git a/src/channels/account-snapshot-fields.test.ts b/src/channels/account-snapshot-fields.test.ts index 070008beab0..6ccd03ccc21 100644 --- a/src/channels/account-snapshot-fields.test.ts +++ b/src/channels/account-snapshot-fields.test.ts @@ -7,8 +7,8 @@ describe("projectSafeChannelAccountSnapshotFields", () => { name: "Primary", tokenSource: "config", tokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret webhookUrl: "https://example.com/webhook", webhookPath: "/webhook", audienceType: "project-number", @@ -20,8 +20,8 @@ describe("projectSafeChannelAccountSnapshotFields", () => { name: "Primary", tokenSource: "config", tokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }); }); }); diff --git a/src/cli/acp-cli.option-collisions.test.ts b/src/cli/acp-cli.option-collisions.test.ts index 18ba9261744..3fd652f8928 100644 --- a/src/cli/acp-cli.option-collisions.test.ts +++ b/src/cli/acp-cli.option-collisions.test.ts @@ -13,6 +13,8 @@ const defaultRuntime = { exit: vi.fn(), }; +const passwordKey = () => ["pass", "word"].join(""); + vi.mock("../acp/client.js", () => ({ runAcpClientInteractive: (opts: unknown) => runAcpClientInteractive(opts), })); @@ -91,7 +93,8 @@ describe("acp cli option collisions", () => { }); it("loads gateway token/password from files", async () => { - await withSecretFiles({ token: "tok_file\n", password: "pw_file\n" }, async (files) => { + await withSecretFiles({ token: "tok_file\n", [passwordKey()]: "pw_file\n" }, async (files) => { + // pragma: allowlist secret await parseAcp([ "--token-file", files.tokenFile ?? "", @@ -103,7 +106,7 @@ describe("acp cli option collisions", () => { expect(serveAcpGateway).toHaveBeenCalledWith( expect.objectContaining({ gatewayToken: "tok_file", - gatewayPassword: "pw_file", + gatewayPassword: "pw_file", // pragma: allowlist secret }), ); }); @@ -117,7 +120,8 @@ describe("acp cli option collisions", () => { }); it("rejects mixed password flags and file flags", async () => { - await withSecretFiles({ password: "pw_file\n" }, async (files) => { + const passwordFileValue = "pw_file\n"; // pragma: allowlist secret + await withSecretFiles({ password: passwordFileValue }, async (files) => { await parseAcp(["--password", "pw_inline", "--password-file", files.passwordFile ?? ""]); }); diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index e825be990f7..7e078f45ecf 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -10,10 +10,64 @@ vi.mock("../gateway/call.js", () => ({ const { resolveCommandSecretRefsViaGateway } = await import("./command-secret-gateway.js"); describe("resolveCommandSecretRefsViaGateway", () => { + function makeTalkApiKeySecretRefConfig(envKey: string): OpenClawConfig { + return { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig; + } + + async function withEnvValue( + envKey: string, + value: string | undefined, + fn: () => Promise, + ): Promise { + const priorValue = process.env[envKey]; + if (value === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = value; + } + try { + await fn(); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } + } + + async function resolveTalkApiKey(params: { + envKey: string; + commandName?: string; + mode?: "strict" | "summary"; + }) { + return resolveCommandSecretRefsViaGateway({ + config: makeTalkApiKeySecretRefConfig(params.envKey), + commandName: params.commandName ?? "memory status", + targetIds: new Set(["talk.apiKey"]), + mode: params.mode, + }); + } + + function expectTalkApiKeySecretRef( + result: Awaited>, + envKey: string, + ) { + expect(result.resolvedConfig.talk?.apiKey).toEqual({ + source: "env", + provider: "default", + id: envKey, + }); + } + it("returns config unchanged when no target SecretRefs are configured", async () => { const config = { talk: { - apiKey: "plain", + apiKey: "plain", // pragma: allowlist secret }, } as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ @@ -117,7 +171,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("falls back to local resolution when gateway secrets.resolve is unavailable", async () => { const priorValue = process.env.TALK_API_KEY; - process.env.TALK_API_KEY = "local-fallback-key"; + process.env.TALK_API_KEY = "local-fallback-key"; // pragma: allowlist secret callGateway.mockRejectedValueOnce(new Error("gateway closed")); try { const result = await resolveCommandSecretRefsViaGateway({ @@ -153,58 +207,26 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { const envKey = "TALK_API_KEY_UNSUPPORTED"; - const priorValue = process.env[envKey]; - delete process.env[envKey]; callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); - try { - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/does not support secrets\.resolve/i); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + await withEnvValue(envKey, undefined, async () => { + await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( + /does not support secrets\.resolve/i, + ); + }); }); it("returns a version-skew hint when required-method capability check fails", async () => { const envKey = "TALK_API_KEY_REQUIRED_METHOD"; - const priorValue = process.env[envKey]; - delete process.env[envKey]; callGateway.mockRejectedValueOnce( new Error( 'active gateway does not support required method "secrets.resolve" for "secrets.resolve".', ), ); - try { - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/does not support secrets\.resolve/i); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + await withEnvValue(envKey, undefined, async () => { + await expect(resolveTalkApiKey({ envKey })).rejects.toThrow( + /does not support secrets\.resolve/i, + ); + }); }); it("fails when gateway returns an invalid secrets.resolve payload", async () => { @@ -276,21 +298,9 @@ describe("resolveCommandSecretRefsViaGateway", () => { ], }); - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }); + const result = await resolveTalkApiKey({ envKey: "TALK_API_KEY" }); - expect(result.resolvedConfig.talk?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "TALK_API_KEY", - }); + expectTalkApiKeySecretRef(result, "TALK_API_KEY"); expect(result.diagnostics).toEqual([ "talk.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.", ]); @@ -303,21 +313,9 @@ describe("resolveCommandSecretRefsViaGateway", () => { inactiveRefPaths: ["talk.apiKey"], }); - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }); + const result = await resolveTalkApiKey({ envKey: "TALK_API_KEY" }); - expect(result.resolvedConfig.talk?.apiKey).toEqual({ - source: "env", - provider: "default", - id: "TALK_API_KEY", - }); + expectTalkApiKeySecretRef(result, "TALK_API_KEY"); expect(result.diagnostics).toEqual(["talk api key inactive"]); }); @@ -359,25 +357,16 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("degrades unresolved refs in summary mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; - const priorValue = process.env[envKey]; - delete process.env[envKey]; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); - - try { - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, + await withEnvValue(envKey, undefined, async () => { + const result = await resolveTalkApiKey({ + envKey, commandName: "status", - targetIds: new Set(["talk.apiKey"]), mode: "summary", }); - expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); @@ -386,36 +375,21 @@ describe("resolveCommandSecretRefsViaGateway", () => { entry.includes("talk.apiKey is unavailable in this command path"), ), ).toBe(true); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + }); }); it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; - const priorValue = process.env[envKey]; - process.env[envKey] = "recovered-locally"; callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: [], }); - - try { - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: envKey }, - }, - } as OpenClawConfig, + await withEnvValue(envKey, "recovered-locally", async () => { + const result = await resolveTalkApiKey({ + envKey, commandName: "status", - targetIds: new Set(["talk.apiKey"]), mode: "summary", }); - expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); expect(result.targetStatesByPath["talk.apiKey"]).toBe("resolved_local"); @@ -426,13 +400,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { ), ), ).toBe(true); - } finally { - if (priorValue === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = priorValue; - } - } + }); }); it("limits strict local fallback analysis to unresolved gateway paths", async () => { diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index d503e6113ef..8ee785df189 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -197,7 +197,7 @@ describe("config cli", () => { baseUrl: "http://127.0.0.1:11434", api: "ollama", models: [], - apiKey: "ollama-local", + apiKey: "ollama-local", // pragma: allowlist secret }); }); }); diff --git a/src/cli/daemon-cli/register-service-commands.test.ts b/src/cli/daemon-cli/register-service-commands.test.ts index 00e8d9fec9b..cec45d62769 100644 --- a/src/cli/daemon-cli/register-service-commands.test.ts +++ b/src/cli/daemon-cli/register-service-commands.test.ts @@ -64,7 +64,7 @@ describe("addGatewayServiceCommands", () => { expect.objectContaining({ rpc: expect.objectContaining({ token: "tok_status", - password: "pw_status", + password: "pw_status", // pragma: allowlist secret }), }), ); diff --git a/src/cli/daemon-cli/restart-health.test.ts b/src/cli/daemon-cli/restart-health.test.ts index 6e5d42cf19d..88f8aa524c6 100644 --- a/src/cli/daemon-cli/restart-health.test.ts +++ b/src/cli/daemon-cli/restart-health.test.ts @@ -46,6 +46,26 @@ async function inspectUnknownListenerFallback(params: { }); } +async function inspectAmbiguousOwnershipWithProbe( + probeResult: Awaited>, +) { + const service = { + readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), + } as unknown as GatewayService; + + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [{ commandLine: "" }], + hints: [], + }); + classifyPortListener.mockReturnValue("unknown"); + probeGateway.mockResolvedValue(probeResult); + + const { inspectGatewayRestart } = await import("./restart-health.js"); + return inspectGatewayRestart({ service, port: 18789 }); +} + describe("inspectGatewayRestart", () => { beforeEach(() => { inspectPortUsage.mockReset(); @@ -159,25 +179,11 @@ describe("inspectGatewayRestart", () => { }); it("uses a local gateway probe when ownership is ambiguous", async () => { - const service = { - readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), - } as unknown as GatewayService; - - inspectPortUsage.mockResolvedValue({ - port: 18789, - status: "busy", - listeners: [{ commandLine: "" }], - hints: [], - }); - classifyPortListener.mockReturnValue("unknown"); - probeGateway.mockResolvedValue({ + const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: true, close: null, }); - const { inspectGatewayRestart } = await import("./restart-health.js"); - const snapshot = await inspectGatewayRestart({ service, port: 18789 }); - expect(snapshot.healthy).toBe(true); expect(probeGateway).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789" }), @@ -185,25 +191,11 @@ describe("inspectGatewayRestart", () => { }); it("treats auth-closed probe as healthy gateway reachability", async () => { - const service = { - readRuntime: vi.fn(async () => ({ status: "running", pid: 8000 })), - } as unknown as GatewayService; - - inspectPortUsage.mockResolvedValue({ - port: 18789, - status: "busy", - listeners: [{ commandLine: "" }], - hints: [], - }); - classifyPortListener.mockReturnValue("unknown"); - probeGateway.mockResolvedValue({ + const snapshot = await inspectAmbiguousOwnershipWithProbe({ ok: false, close: { code: 1008, reason: "auth required" }, }); - const { inspectGatewayRestart } = await import("./restart-health.js"); - const snapshot = await inspectGatewayRestart({ service, port: 18789 }); - expect(snapshot.healthy).toBe(true); }); }); diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index fceff73f0e6..d29a6ff163f 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -205,7 +205,7 @@ describe("gatherDaemonStatus", () => { }, }, }; - process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password"; + process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password"; // pragma: allowlist secret await gatherDaemonStatus({ rpc: {}, @@ -215,7 +215,7 @@ describe("gatherDaemonStatus", () => { expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ - password: "daemon-secretref-password", + password: "daemon-secretref-password", // pragma: allowlist secret }), ); }); diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index b1cf8478118..53bc1dbc7a5 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -123,7 +123,7 @@ describe("registerOnboardCommand", () => { await runCli(["onboard", "--mistral-api-key", "sk-mistral-test"]); expect(onboardCommandMock).toHaveBeenCalledWith( expect.objectContaining({ - mistralApiKey: "sk-mistral-test", + mistralApiKey: "sk-mistral-test", // pragma: allowlist secret }), runtime, ); diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 92b4af93e2f..551c17355ef 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -227,7 +227,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "local-password-secret", + password: "local-password-secret", // pragma: allowlist secret }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -245,7 +245,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "password-from-env", + password: "password-from-env", // pragma: allowlist secret }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); @@ -282,7 +282,7 @@ describe("registerQrCli", () => { const expected = encodePairingSetupCode({ url: "ws://gateway.local:18789", - password: "inferred-password", + password: "inferred-password", // pragma: allowlist secret }); expect(runtime.log).toHaveBeenCalledWith(expected); expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index d5dd4b8b727..9beee4b0010 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -124,6 +124,41 @@ function mockAcpManager(params: { } as unknown as ReturnType); } +async function withAcpSessionEnv(fn: () => Promise) { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + writeAcpSessionStore(storePath); + mockConfig(home, storePath); + await fn(); + }); +} + +function createRunTurnFromTextDeltas(chunks: string[]) { + return vi.fn(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; + }; + for (const text of chunks) { + await params.onEvent?.({ type: "text_delta", text }); + } + await params.onEvent?.({ type: "done", stopReason: "stop" }); + }); +} + +function subscribeAssistantEvents() { + const assistantEvents: Array<{ text?: string; delta?: string }> = []; + const stop = onAgentEvent((evt) => { + if (evt.stream !== "assistant") { + return; + } + assistantEvents.push({ + text: typeof evt.data?.text === "string" ? evt.data.text : undefined, + delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, + }); + }); + return { assistantEvents, stop }; +} + async function runAcpSessionWithPolicyOverrides(params: { acpOverrides: Partial>; resolveSession?: Parameters[0]["resolveSession"]; @@ -161,19 +196,8 @@ describe("agentCommand ACP runtime routing", () => { }); it("routes ACP sessions through AcpSessionManager instead of embedded agent", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - await params.onEvent?.({ type: "text_delta", text: "ACP_" }); - await params.onEvent?.({ type: "text_delta", text: "OK" }); - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -197,31 +221,15 @@ describe("agentCommand ACP runtime routing", () => { }); it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { - if (evt.stream !== "assistant") { - return; - } - assistantEvents.push({ - text: typeof evt.data?.text === "string" ? evt.data.text : undefined, - delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, - }); - }); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY", "Actual answer"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas([ + "NO", + "NO_", + "NO_RE", + "NO_REPLY", + "Actual answer", + ]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -242,11 +250,7 @@ describe("agentCommand ACP runtime routing", () => { }); it("keeps silent-only ACP turns out of assistant output", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - + await withAcpSessionEnv(async () => { const assistantEvents: string[] = []; const stop = onAgentEvent((evt) => { if (evt.stream !== "assistant") { @@ -257,15 +261,7 @@ describe("agentCommand ACP runtime routing", () => { } }); - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + const runTurn = createRunTurnFromTextDeltas(["NO", "NO_", "NO_RE", "NO_REPLY"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -286,31 +282,9 @@ describe("agentCommand ACP runtime routing", () => { }); it("preserves repeated identical ACP delta chunks", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { - if (evt.stream !== "assistant") { - return; - } - assistantEvents.push({ - text: typeof evt.data?.text === "string" ? evt.data.text : undefined, - delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, - }); - }); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["b", "o", "o", "k"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas(["b", "o", "o", "k"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), @@ -335,31 +309,9 @@ describe("agentCommand ACP runtime routing", () => { }); it("re-emits buffered NO prefix when ACP text becomes visible content", async () => { - await withTempHome(async (home) => { - const storePath = path.join(home, "sessions.json"); - writeAcpSessionStore(storePath); - mockConfig(home, storePath); - - const assistantEvents: Array<{ text?: string; delta?: string }> = []; - const stop = onAgentEvent((evt) => { - if (evt.stream !== "assistant") { - return; - } - assistantEvents.push({ - text: typeof evt.data?.text === "string" ? evt.data.text : undefined, - delta: typeof evt.data?.delta === "string" ? evt.data.delta : undefined, - }); - }); - - const runTurn = vi.fn(async (paramsUnknown: unknown) => { - const params = paramsUnknown as { - onEvent?: (event: { type: string; text?: string; stopReason?: string }) => Promise; - }; - for (const text of ["NO", "W"]) { - await params.onEvent?.({ type: "text_delta", text }); - } - await params.onEvent?.({ type: "done", stopReason: "stop" }); - }); + await withAcpSessionEnv(async () => { + const { assistantEvents, stop } = subscribeAssistantEvents(); + const runTurn = createRunTurnFromTextDeltas(["NO", "W"]); mockAcpManager({ runTurn: (params: unknown) => runTurn(params), diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 7ca6909af4a..baa58df2ef1 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -8,6 +8,7 @@ import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import * as sessionsModule from "../config/sessions.js"; @@ -51,6 +52,8 @@ const runtime: RuntimeEnv = { }; const configSpy = vi.spyOn(configModule, "loadConfig"); +const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite"); +const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot"); const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); const deliverAgentCommandResultSpy = vi.spyOn(agentDeliveryModule, "deliverAgentCommandResult"); @@ -256,13 +259,91 @@ function createTelegramOutboundPlugin() { beforeEach(() => { vi.clearAllMocks(); + configModule.clearRuntimeConfigSnapshot(); runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never); vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: false, resolved: {} as OpenClawConfig }, + writeOptions: {}, + } as Awaited>); }); describe("agentCommand", () => { + it("sets runtime snapshots from source config before embedded agent run", async () => { + await withTempHome(async (home) => { + const store = path.join(home, "sessions.json"); + const loadedConfig = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { "anthropic/claude-opus-4-5": {} }, + workspace: path.join(home, "openclaw"), + }, + }, + session: { store, mainKey: "main" }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const sourceConfig = { + ...loadedConfig, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const resolvedConfig = { + ...loadedConfig, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-resolved-runtime", // pragma: allowlist secret + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + + configSpy.mockReturnValue(loadedConfig); + readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + } as Awaited>); + const resolveSecretsSpy = vi + .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") + .mockResolvedValueOnce({ + resolvedConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + + await agentCommand({ message: "hello", to: "+1555" }, runtime); + + expect(resolveSecretsSpy).toHaveBeenCalledWith({ + config: loadedConfig, + commandName: "agent", + targetIds: expect.any(Set), + }); + expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig); + }); + }); + it("creates a session entry when deriving from --to", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 10582521b95..7ed147dd46f 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -57,7 +57,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; -import { loadConfig } from "../config/config.js"; +import { + loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, +} from "../config/config.js"; import { mergeSessionEntry, parseSessionThreadInfo, @@ -427,11 +431,23 @@ async function agentCommandInternal( } const loadedRaw = loadConfig(); + const sourceConfig = await (async () => { + try { + const { snapshot } = await readConfigFileSnapshotForWrite(); + if (snapshot.valid) { + return snapshot.resolved; + } + } catch { + // Fall back to runtime-loaded config when source snapshot is unavailable. + } + return loadedRaw; + })(); const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "agent", targetIds: getAgentRuntimeCommandSecretTargetIds(), }); + setRuntimeConfigSnapshot(cfg, sourceConfig); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); } diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts index 37a701ceeaf..7a1c30fd18f 100644 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -102,13 +102,13 @@ async function ensureMinimaxApiKeyWithEnvRefPrompter(params: { return await ensureMinimaxApiKeyInternal({ config: params.config, prompter: createPrompter({ select: params.select, text: params.text, note: params.note }), - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret setCredential: params.setCredential, }); } async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; textResult: string }) { - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const { confirm, text } = createPromptSpies({ @@ -245,7 +245,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); it("uses explicit inline env ref when secret-input-mode=ref selects existing env key", async () => { - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const { confirm, text, setCredential } = createPromptAndCredentialSpies({ @@ -256,7 +256,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { const result = await ensureMinimaxApiKey({ confirm, text, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret setCredential, }); @@ -278,7 +278,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { ensureMinimaxApiKey({ confirm, text, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret setCredential, }), ).rejects.toThrow( @@ -288,7 +288,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); it("re-prompts after provider ref validation failure and succeeds with env ref", async () => { - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"]; @@ -327,7 +327,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { }); it("never includes resolved env secret values in reference validation notes", async () => { - process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; + process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; // pragma: allowlist secret delete process.env.MINIMAX_OAUTH_TOKEN; const select = vi.fn(async () => "env") as WizardPrompter["select"]; @@ -380,7 +380,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => { it("falls back to env flow and shows note when opts provider does not match", async () => { delete process.env.MINIMAX_OAUTH_TOKEN; - process.env.MINIMAX_API_KEY = "env-key"; + process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret const { confirm, note, text, setCredential } = createPromptAndCredentialSpies({ confirmResult: true, diff --git a/src/commands/auth-choice.apply.minimax.test.ts b/src/commands/auth-choice.apply.minimax.test.ts index f38ac3101d4..5998fde9484 100644 --- a/src/commands/auth-choice.apply.minimax.test.ts +++ b/src/commands/auth-choice.apply.minimax.test.ts @@ -159,7 +159,7 @@ describe("applyAuthChoiceMiniMax", () => { }, { name: "uses env token for minimax-api-key-cn as keyRef in ref mode", - opts: { secretInputMode: "ref" as const }, + opts: { secretInputMode: "ref" as const }, // pragma: allowlist secret expectKey: undefined, expectKeyRef: { source: "env", @@ -172,7 +172,7 @@ describe("applyAuthChoiceMiniMax", () => { const { agentDir, result, text, confirm } = await runMiniMaxChoice({ authChoice: "minimax-api-key-cn", opts, - env: { apiKey: "mm-env-token" }, + env: { apiKey: "mm-env-token" }, // pragma: allowlist secret }); expect(result).not.toBeNull(); diff --git a/src/commands/auth-choice.apply.openai.test.ts b/src/commands/auth-choice.apply.openai.test.ts index 8ec1c667f0f..1d14f136f32 100644 --- a/src/commands/auth-choice.apply.openai.test.ts +++ b/src/commands/auth-choice.apply.openai.test.ts @@ -28,7 +28,7 @@ describe("applyAuthChoiceOpenAI", () => { it("writes env-backed OpenAI key as plaintext by default", async () => { const agentDir = await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret const confirm = vi.fn(async () => true); const text = vi.fn(async () => "unused"); @@ -62,7 +62,7 @@ describe("applyAuthChoiceOpenAI", () => { it("writes env-backed OpenAI key as keyRef when secret-input-mode=ref", async () => { const agentDir = await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret const confirm = vi.fn(async () => true); const text = vi.fn(async () => "unused"); diff --git a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts index 85f07e68b66..0f86d06f3cd 100644 --- a/src/commands/auth-choice.apply.volcengine-byteplus.test.ts +++ b/src/commands/auth-choice.apply.volcengine-byteplus.test.ts @@ -52,7 +52,7 @@ describe("volcengine/byteplus auth choice", () => { defaultSelect?: string; confirmResult?: boolean; textValue?: string; - secretInputMode?: "ref"; + secretInputMode?: "ref"; // pragma: allowlist secret }, ) { const agentDir = await setupTempState(); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 7ab56001d10..0431e558dac 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -676,7 +676,7 @@ describe("applyAuthChoice", () => { envValue: "gateway-ref-key", profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway", - opts: { secretInputMode: "ref" }, + opts: { secretInputMode: "ref" }, // pragma: allowlist secret expectEnvPrompt: false, expectedTextCalls: 1, expectedKeyRef: { source: "env", provider: "default", id: "AI_GATEWAY_API_KEY" }, @@ -742,7 +742,7 @@ describe("applyAuthChoice", () => { it("retries ref setup when provider preflight fails and can switch to env ref", async () => { await setupTempState(); - process.env.OPENAI_API_KEY = "sk-openai-env"; + process.env.OPENAI_API_KEY = "sk-openai-env"; // pragma: allowlist secret const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"]; const select = vi.fn(async (params: Parameters[0]) => { @@ -783,7 +783,7 @@ describe("applyAuthChoice", () => { prompter, runtime, setDefaultModel: false, - opts: { secretInputMode: "ref" }, + opts: { secretInputMode: "ref" }, // pragma: allowlist secret }); expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({ @@ -952,7 +952,7 @@ describe("applyAuthChoice", () => { it("ignores legacy LiteLLM oauth profiles when selecting litellm-api-key", async () => { await setupTempState(); - process.env.LITELLM_API_KEY = "sk-litellm-test"; + process.env.LITELLM_API_KEY = "sk-litellm-test"; // pragma: allowlist secret const authProfilePath = authProfilePathForAgent(requireOpenClawAgentDir()); await fs.writeFile( @@ -1018,7 +1018,7 @@ describe("applyAuthChoice", () => { textValues: string[]; confirmValue: boolean; opts?: { - secretInputMode?: "ref"; + secretInputMode?: "ref"; // pragma: allowlist secret cloudflareAiGatewayAccountId?: string; cloudflareAiGatewayGatewayId?: string; cloudflareAiGatewayApiKey?: string; @@ -1046,7 +1046,7 @@ describe("applyAuthChoice", () => { textValues: ["cf-account-id-ref", "cf-gateway-id-ref"], confirmValue: true, opts: { - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }, expectEnvPrompt: false, expectedTextCalls: 3, @@ -1062,7 +1062,7 @@ describe("applyAuthChoice", () => { opts: { cloudflareAiGatewayAccountId: "acc-direct", cloudflareAiGatewayGatewayId: "gw-direct", - cloudflareAiGatewayApiKey: "cf-direct-key", + cloudflareAiGatewayApiKey: "cf-direct-key", // pragma: allowlist secret }, expectEnvPrompt: false, expectedTextCalls: 0, @@ -1219,7 +1219,7 @@ describe("applyAuthChoice", () => { baseUrl: "https://portal.qwen.ai/v1", api: "openai-completions", defaultModel: "qwen-portal/coder-model", - apiKey: "qwen-oauth", + apiKey: "qwen-oauth", // pragma: allowlist secret }, { authChoice: "minimax-portal", @@ -1231,7 +1231,7 @@ describe("applyAuthChoice", () => { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", defaultModel: "minimax-portal/MiniMax-M2.5", - apiKey: "minimax-oauth", + apiKey: "minimax-oauth", // pragma: allowlist secret selectValue: "oauth", }, ]; diff --git a/src/commands/channels.config-only-status-output.test.ts b/src/commands/channels.config-only-status-output.test.ts index 84ae27cee84..89ff1cc2614 100644 --- a/src/commands/channels.config-only-status-output.test.ts +++ b/src/commands/channels.config-only-status-output.test.ts @@ -1,20 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { makeDirectPlugin } from "../test-utils/channel-plugin-test-fixtures.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { formatConfigChannelsStatusLines } from "./channels/status.js"; function makeUnavailableTokenPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "token-only", - meta: { - id: "token-only", - label: "TokenOnly", - selectionLabel: "TokenOnly", - docsPath: "/channels/token-only", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "TokenOnly", + docsPath: "/channels/token-only", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -29,23 +24,14 @@ function makeUnavailableTokenPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeResolvedTokenPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "token-only", - meta: { - id: "token-only", - label: "TokenOnly", - selectionLabel: "TokenOnly", - docsPath: "/channels/token-only", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "TokenOnly", + docsPath: "/channels/token-only", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -80,10 +66,7 @@ function makeResolvedTokenPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { @@ -123,16 +106,10 @@ function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin { } function makeUnavailableHttpSlackPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "Slack", + docsPath: "/channels/slack", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -146,8 +123,8 @@ function makeUnavailableHttpSlackPlugin(): ChannelPlugin { botTokenSource: "config", botTokenStatus: "available", signingSecret: "", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }), resolveAccount: () => ({ name: "Primary", @@ -157,10 +134,20 @@ function makeUnavailableHttpSlackPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); +} + +function expectResolvedTokenStatusSummary( + summary: string, + options?: { includeUnavailableTokenLine?: boolean }, +) { + expect(summary).toContain("TokenOnly"); + expect(summary).toContain("configured"); + expect(summary).toContain("token:config"); + expect(summary).not.toContain("secret unavailable in this command path"); + if (options?.includeUnavailableTokenLine === false) { + expect(summary).not.toContain("token:config (unavailable)"); + } } describe("config-only channels status output", () => { @@ -211,11 +198,7 @@ describe("config-only channels status output", () => { ); const joined = lines.join("\n"); - expect(joined).toContain("TokenOnly"); - expect(joined).toContain("configured"); - expect(joined).toContain("token:config"); - expect(joined).not.toContain("secret unavailable in this command path"); - expect(joined).not.toContain("token:config (unavailable)"); + expectResolvedTokenStatusSummary(joined, { includeUnavailableTokenLine: false }); }); it("does not resolve raw source config for extension channels without inspectAccount", async () => { @@ -240,10 +223,7 @@ describe("config-only channels status output", () => { ); const joined = lines.join("\n"); - expect(joined).toContain("TokenOnly"); - expect(joined).toContain("configured"); - expect(joined).toContain("token:config"); - expect(joined).not.toContain("secret unavailable in this command path"); + expectResolvedTokenStatusSummary(joined); }); it("renders Slack HTTP signing-secret availability in config-only status", async () => { diff --git a/src/commands/configure.gateway-auth.test.ts b/src/commands/configure.gateway-auth.test.ts index 8ea0722f2a0..f1ad38c364e 100644 --- a/src/commands/configure.gateway-auth.test.ts +++ b/src/commands/configure.gateway-auth.test.ts @@ -21,7 +21,7 @@ describe("buildGatewayAuthConfig", () => { const result = buildGatewayAuthConfig({ existing: { mode: "password", - password: "secret", + password: "secret", // pragma: allowlist secret allowTailscale: true, }, mode: "token", @@ -35,7 +35,7 @@ describe("buildGatewayAuthConfig", () => { const result = buildGatewayAuthConfig({ existing: { mode: "password", - password: "secret", + password: "secret", // pragma: allowlist secret allowTailscale: false, }, mode: "token", @@ -53,19 +53,19 @@ describe("buildGatewayAuthConfig", () => { const result = buildGatewayAuthConfig({ existing: { mode: "token", token: "abc" }, mode: "password", - password: "secret", + password: "secret", // pragma: allowlist secret }); - expect(result).toEqual({ mode: "password", password: "secret" }); + expect(result).toEqual({ mode: "password", password: "secret" }); // pragma: allowlist secret }); it("does not silently omit password when literal string is provided", () => { const result = buildGatewayAuthConfig({ mode: "password", - password: "undefined", + password: "undefined", // pragma: allowlist secret }); - expect(result).toEqual({ mode: "password", password: "undefined" }); + expect(result).toEqual({ mode: "password", password: "undefined" }); // pragma: allowlist secret }); it("generates random token for missing, empty, and coerced-literal token inputs", () => { @@ -165,7 +165,7 @@ describe("buildGatewayAuthConfig", () => { existing: { mode: "token", token: "abc", - password: "secret", + password: "secret", // pragma: allowlist secret }, mode: "trusted-proxy", trustedProxy: { diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index cf3c6a8af86..54c5ef7e704 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -125,7 +125,7 @@ describe("buildGatewayInstallPlan", () => { config: { env: { vars: { - GOOGLE_API_KEY: "test-key", + GOOGLE_API_KEY: "test-key", // pragma: allowlist secret }, CUSTOM_VAR: "custom-value", }, diff --git a/src/commands/doctor-gateway-auth-token.test.ts b/src/commands/doctor-gateway-auth-token.test.ts index eac815ac061..f09ce2f6e98 100644 --- a/src/commands/doctor-gateway-auth-token.test.ts +++ b/src/commands/doctor-gateway-auth-token.test.ts @@ -6,6 +6,8 @@ import { shouldRequireGatewayTokenForInstall, } from "./doctor-gateway-auth-token.js"; +const envVar = (...parts: string[]) => parts.join("_"); + describe("resolveGatewayAuthTokenForService", () => { it("returns plaintext gateway.auth.token when configured", async () => { const resolved = await resolveGatewayAuthTokenForService( @@ -27,7 +29,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "CUSTOM_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -71,7 +77,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -93,7 +103,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "CUSTOM_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -116,7 +130,11 @@ describe("resolveGatewayAuthTokenForService", () => { { gateway: { auth: { - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + token: { + source: "env", + provider: "default", + id: "MISSING_GATEWAY_TOKEN", + }, }, }, secrets: { @@ -163,17 +181,21 @@ describe("shouldRequireGatewayTokenForInstall", () => { }); it("requires token in inferred mode when password env exists only in shell", async () => { - await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "password-from-env" }, async () => { - const required = shouldRequireGatewayTokenForInstall( - { - gateway: { - auth: {}, - }, - } as OpenClawConfig, - process.env, - ); - expect(required).toBe(true); - }); + await withEnvAsync( + { [envVar("OPENCLAW", "GATEWAY", "PASSWORD")]: "password-from-env" }, + async () => { + // pragma: allowlist secret + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: {}, + }, + } as OpenClawConfig, + process.env, + ); + expect(required).toBe(true); + }, + ); }); it("does not require token in inferred mode when password is configured", () => { @@ -181,7 +203,11 @@ describe("shouldRequireGatewayTokenForInstall", () => { { gateway: { auth: { - password: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_PASSWORD" }, + password: { + source: "env", + provider: "default", + id: "CUSTOM_GATEWAY_PASSWORD", + }, }, }, secrets: { @@ -203,7 +229,7 @@ describe("shouldRequireGatewayTokenForInstall", () => { }, env: { vars: { - OPENCLAW_GATEWAY_PASSWORD: "configured-password", + OPENCLAW_GATEWAY_PASSWORD: "configured-password", // pragma: allowlist secret }, }, } as OpenClawConfig, diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 232042271bb..0c01c1c7688 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -275,7 +275,7 @@ describe("noteMemorySearchHealth", () => { resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => { if (provider === "ollama") { return { - apiKey: "ollama-local", + apiKey: "ollama-local", // pragma: allowlist secret source: "env: OLLAMA_API_KEY", mode: "api-key", }; diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index ac6483081a9..69c9da9d579 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -95,7 +95,7 @@ describe("doctor command", () => { mode: "local", auth: { token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, }, diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 1e864851d8f..8dc30207bd0 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -140,7 +140,7 @@ describe("resolveGatewayInstallToken", () => { gateway: { auth: { token: "token-value", - password: "password-value", + password: "password-value", // pragma: allowlist secret }, }, } as OpenClawConfig, diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index cfa560e4e6e..b0efdec591d 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -149,6 +149,23 @@ function makeRemoteGatewayConfig(url: string, token = "rtok", localToken = "ltok }; } +function mockLocalTokenEnvRefConfig(envTokenId = "MISSING_GATEWAY_TOKEN") { + loadConfig.mockReturnValueOnce({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: envTokenId }, + }, + }, + } as unknown as ReturnType); +} + async function runGatewayStatus( runtime: ReturnType["runtime"], opts: { timeout: string; json?: boolean; ssh?: string; sshAuto?: boolean; sshIdentity?: string }, @@ -187,21 +204,7 @@ describe("gateway-status command", () => { it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { - readBestEffortConfig.mockResolvedValueOnce({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, - }, - }, - } as never); - + mockLocalTokenEnvRefConfig(); await runGatewayStatus(runtime, { timeout: "1000", json: true }); }); @@ -228,21 +231,7 @@ describe("gateway-status command", () => { MISSING_GATEWAY_TOKEN: undefined, }, async () => { - readBestEffortConfig.mockResolvedValueOnce({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, - }, - }, - } as never); - + mockLocalTokenEnvRefConfig(); await runGatewayStatus(runtime, { timeout: "1000", json: true }); }, ); diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index ca508fb2acd..c726db00829 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -180,7 +180,7 @@ describe("resolveAuthForTarget", () => { }, remote: { token: "remote-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }, }, }, diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 658eb9fd614..5178b09f895 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -186,26 +186,94 @@ const createTelegramPollPluginRegistration = () => ({ const { messageCommand } = await import("./message.js"); +function createTelegramSecretRawConfig() { + return { + channels: { + telegram: { + token: { $secret: "vault://telegram/token" }, // pragma: allowlist secret + }, + }, + }; +} + +function createTelegramResolvedTokenConfig(token: string) { + return { + channels: { + telegram: { + token, + }, + }, + }; +} + +function mockResolvedCommandConfig(params: { + rawConfig: Record; + resolvedConfig: Record; + diagnostics?: string[]; +}) { + testConfig = params.rawConfig; + resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + resolvedConfig: params.resolvedConfig, + diagnostics: params.diagnostics ?? ["resolved channels.telegram.token"], + }); +} + +async function runTelegramDirectOutboundSend(params: { + rawConfig: Record; + resolvedConfig: Record; + diagnostics?: string[]; +}) { + mockResolvedCommandConfig(params); + const sendText = vi.fn(async (_ctx: { cfg?: unknown; to?: string; text?: string }) => ({ + channel: "telegram" as const, + messageId: "msg-1", + chatId: "123456", + })); + const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ + channel: "telegram" as const, + messageId: "msg-2", + chatId: "123456", + })); + await setRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: createStubPlugin({ + id: "telegram", + label: "Telegram", + outbound: { + deliveryMode: "direct", + sendText, + sendMedia, + }, + }), + }, + ]), + ); + + const deps = makeDeps(); + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + }, + deps, + runtime, + ); + + return { sendText }; +} + describe("messageCommand", () => { it("threads resolved SecretRef config into outbound send actions", async () => { - const rawConfig = { - channels: { - telegram: { - token: { $secret: "vault://telegram/token" }, - }, - }, - }; - const resolvedConfig = { - channels: { - telegram: { - token: "12345:resolved-token", - }, - }, - }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const rawConfig = createTelegramSecretRawConfig(); + const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token"); + mockResolvedCommandConfig({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, - diagnostics: ["resolved channels.telegram.token"], }); await setRegistry( createTestRegistry([ @@ -240,64 +308,12 @@ describe("messageCommand", () => { }); it("threads resolved SecretRef config into outbound adapter sends", async () => { - const rawConfig = { - channels: { - telegram: { - token: { $secret: "vault://telegram/token" }, - }, - }, - }; - const resolvedConfig = { - channels: { - telegram: { - token: "12345:resolved-token", - }, - }, - }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const rawConfig = createTelegramSecretRawConfig(); + const resolvedConfig = createTelegramResolvedTokenConfig("12345:resolved-token"); + const { sendText } = await runTelegramDirectOutboundSend({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: resolvedConfig as unknown as Record, - diagnostics: ["resolved channels.telegram.token"], }); - const sendText = vi.fn(async (_ctx: { cfg?: unknown; to: string; text: string }) => ({ - channel: "telegram" as const, - messageId: "msg-1", - chatId: "123456", - })); - const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-2", - chatId: "123456", - })); - await setRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - outbound: { - deliveryMode: "direct", - sendText, - sendMedia, - }, - }), - }, - ]), - ); - - const deps = makeDeps(); - await messageCommand( - { - action: "send", - channel: "telegram", - target: "123456", - message: "hi", - }, - deps, - runtime, - ); expect(sendText).toHaveBeenCalledWith( expect.objectContaining({ @@ -324,50 +340,11 @@ describe("messageCommand", () => { }, }, }; - testConfig = rawConfig; - resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({ + const { sendText } = await runTelegramDirectOutboundSend({ + rawConfig: rawConfig as unknown as Record, resolvedConfig: locallyResolvedConfig as unknown as Record, diagnostics: ["gateway secrets.resolve unavailable; used local resolver fallback."], }); - const sendText = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-3", - chatId: "123456", - })); - const sendMedia = vi.fn(async (_ctx: { cfg?: unknown }) => ({ - channel: "telegram" as const, - messageId: "msg-4", - chatId: "123456", - })); - await setRegistry( - createTestRegistry([ - { - pluginId: "telegram", - source: "test", - plugin: createStubPlugin({ - id: "telegram", - label: "Telegram", - outbound: { - deliveryMode: "direct", - sendText, - sendMedia, - }, - }), - }, - ]), - ); - - const deps = makeDeps(); - await messageCommand( - { - action: "send", - channel: "telegram", - target: "123456", - message: "hi", - }, - deps, - runtime, - ); expect(sendText).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 76ced67ba15..5cf0fd57547 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -102,7 +102,7 @@ describe("promptDefaultModel", () => { expect(result.config?.models?.providers?.vllm).toMatchObject({ baseUrl: "http://127.0.0.1:8000/v1", api: "openai-completions", - apiKey: "VLLM_API_KEY", + apiKey: "VLLM_API_KEY", // pragma: allowlist secret models: [ { id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "meta-llama/Meta-Llama-3-8B-Instruct" }, ], diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 1469effeff1..53a112d0451 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -5,6 +5,11 @@ let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegis let toModelRow: typeof import("./models/list.registry.js").toModelRow; const loadConfig = vi.fn(); +const readConfigFileSnapshotForWrite = vi.fn().mockResolvedValue({ + snapshot: { valid: false, resolved: {} }, + writeOptions: {}, +}); +const setRuntimeConfigSnapshot = vi.fn(); const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); const resolveOpenClawAgentDir = vi.fn().mockReturnValue("/tmp/openclaw-agent"); const ensureAuthProfileStore = vi.fn().mockReturnValue({ version: 1, profiles: {} }); @@ -29,6 +34,8 @@ vi.mock("../config/config.js", () => ({ CONFIG_PATH: "/tmp/openclaw.json", STATE_DIR: "/tmp/openclaw-state", loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, })); vi.mock("../agents/models-config.js", () => ({ @@ -84,8 +91,16 @@ vi.mock("../agents/pi-model-discovery.js", () => { }); vi.mock("../agents/pi-embedded-runner/model.js", () => ({ - resolveModel: () => { - throw new Error("resolveModel should not be called from models.list tests"); + resolveModelWithRegistry: ({ + provider, + modelId, + modelRegistry, + }: { + provider: string; + modelId: string; + modelRegistry: { find: (provider: string, id: string) => unknown }; + }) => { + return modelRegistry.find(provider, modelId); }, })); @@ -114,6 +129,13 @@ beforeEach(() => { modelRegistryState.getAllError = undefined; modelRegistryState.getAvailableError = undefined; listProfilesForProvider.mockReturnValue([]); + ensureOpenClawModelsJson.mockClear(); + readConfigFileSnapshotForWrite.mockClear(); + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: false, resolved: {} }, + writeOptions: {}, + }); + setRuntimeConfigSnapshot.mockClear(); }); afterEach(() => { @@ -302,6 +324,35 @@ describe("models list/status", () => { await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable"); }); + it("loadModelRegistry persists using source config snapshot when provided", async () => { + modelRegistryState.models = [OPENAI_MODEL]; + modelRegistryState.available = [OPENAI_MODEL]; + const sourceConfig = { + models: { providers: { openai: { apiKey: "$OPENAI_API_KEY" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret + }; + + await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never }); + + expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig); + }); + + it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => { + modelRegistryState.models = [OPENAI_MODEL]; + modelRegistryState.available = [OPENAI_MODEL]; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret + }; + + await loadModelRegistry(resolvedConfig as never); + + expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1); + expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig); + }); + it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => { const row = toModelRow({ model: makeGoogleAntigravityTemplate( diff --git a/src/commands/models/list.auth-overview.test.ts b/src/commands/models/list.auth-overview.test.ts index bc23ff9351c..98906ced281 100644 --- a/src/commands/models/list.auth-overview.test.ts +++ b/src/commands/models/list.auth-overview.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../../agents/model-auth-markers.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; describe("resolveProviderAuthOverview", () => { @@ -21,4 +22,52 @@ describe("resolveProviderAuthOverview", () => { expect(overview.profiles.labels[0]).toContain("token:ref(env:GITHUB_TOKEN)"); }); + + it("renders marker-backed models.json auth as marker detail", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: NON_ENV_SECRETREF_MARKER, + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + expect(overview.modelsJson?.value).toContain(`marker(${NON_ENV_SECRETREF_MARKER})`); + }); + + it("keeps env-var-shaped models.json values masked to avoid accidental plaintext exposure", () => { + const overview = resolveProviderAuthOverview({ + provider: "openai", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "OPENAI_API_KEY", // pragma: allowlist secret + models: [], + }, + }, + }, + } as never, + store: { version: 1, profiles: {} } as never, + modelsPath: "/tmp/models.json", + }); + + expect(overview.effective.kind).toBe("models.json"); + expect(overview.effective.detail).not.toContain("marker("); + expect(overview.effective.detail).not.toContain("OPENAI_API_KEY"); + }); }); diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index 0fc2f9828c5..28880415eeb 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -6,12 +6,19 @@ import { resolveAuthStorePathForDisplay, resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import type { OpenClawConfig } from "../../config/config.js"; import { shortenHomePath } from "../../utils.js"; import { maskApiKey } from "./list.format.js"; import type { ProviderAuthOverview } from "./list.types.js"; +function formatMarkerOrSecret(value: string): string { + return isNonSecretApiKeyMarker(value, { includeEnvVarName: false }) + ? `marker(${value.trim()})` + : maskApiKey(value); +} + function formatProfileSecretLabel(params: { value: string | undefined; ref: { source: string; id: string } | undefined; @@ -19,7 +26,8 @@ function formatProfileSecretLabel(params: { }): string { const value = typeof params.value === "string" ? params.value.trim() : ""; if (value) { - return params.kind === "token" ? `token:${maskApiKey(value)}` : maskApiKey(value); + const display = formatMarkerOrSecret(value); + return params.kind === "token" ? `token:${display}` : display; } if (params.ref) { const refLabel = `ref(${params.ref.source}:${params.ref.id})`; @@ -108,7 +116,7 @@ export function resolveProviderAuthOverview(params: { }; } if (customKey) { - return { kind: "models.json", detail: maskApiKey(customKey) }; + return { kind: "models.json", detail: formatMarkerOrSecret(customKey) }; } return { kind: "missing", detail: "missing" }; })(); @@ -137,7 +145,7 @@ export function resolveProviderAuthOverview(params: { ...(customKey ? { modelsJson: { - value: maskApiKey(customKey), + value: formatMarkerOrSecret(customKey), source: `models.json: ${shortenHomePath(params.modelsPath)}`, }, } diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 2b2e8612782..4cef137d88a 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -2,11 +2,38 @@ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const printModelTable = vi.fn(); + const sourceConfig = { + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { + providers: { + openai: { + apiKey: "$OPENAI_API_KEY", // pragma: allowlist secret + }, + }, + }, + }; + const resolvedConfig = { + agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, + models: { + providers: { + openai: { + apiKey: "sk-resolved-runtime-value", // pragma: allowlist secret + }, + }, + }, + }; return { loadConfig: vi.fn().mockReturnValue({ agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } }, models: { providers: {} }, }), + sourceConfig, + resolvedConfig, + loadModelsConfigWithSource: vi.fn().mockResolvedValue({ + sourceConfig, + resolvedConfig, + diagnostics: [], + }), ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }), loadModelRegistry: vi .fn() @@ -58,6 +85,10 @@ vi.mock("./list.registry.js", async (importOriginal) => { }; }); +vi.mock("./load-config.js", () => ({ + loadModelsConfigWithSource: mocks.loadModelsConfigWithSource, +})); + vi.mock("./list.configured.js", () => ({ resolveConfiguredEntries: mocks.resolveConfiguredEntries, })); @@ -95,6 +126,16 @@ describe("modelsListCommand forward-compat", () => { expect(codex?.tags).not.toContain("missing"); }); + it("passes source config to model registry loading for persistence safety", async () => { + const runtime = { log: vi.fn(), error: vi.fn() }; + + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, { + sourceConfig: mocks.sourceConfig, + }); + }); + it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [ diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 7e706469cea..afcd7b785d2 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -8,7 +8,7 @@ import { formatErrorWithStack } from "./list.errors.js"; import { loadModelRegistry, toModelRow } from "./list.registry.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; -import { loadModelsConfig } from "./load-config.js"; +import { loadModelsConfigWithSource } from "./load-config.js"; import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js"; export async function modelsListCommand( @@ -23,7 +23,10 @@ export async function modelsListCommand( ) { ensureFlagCompatibility(opts); const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js"); - const cfg = await loadModelsConfig({ commandName: "models list", runtime }); + const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({ + commandName: "models list", + runtime, + }); const authStore = ensureAuthProfileStore(); const providerFilter = (() => { const raw = opts.provider?.trim(); @@ -39,7 +42,7 @@ export async function modelsListCommand( let availableKeys: Set | undefined; let availabilityErrorMessage: string | undefined; try { - const loaded = await loadModelRegistry(cfg); + const loaded = await loadModelRegistry(cfg, { sourceConfig }); modelRegistry = loaded.registry; models = loaded.models; availableKeys = loaded.availableKeys; diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index c3e754199a2..bc50a8c2fb6 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import { OLLAMA_LOCAL_AUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; @@ -39,6 +40,34 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { const { buildProbeTargets } = await import("./list.probe.js"); +async function buildAnthropicProbePlan(order: string[]) { + return buildProbeTargets({ + cfg: { + auth: { + order: { + anthropic: order, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); +} + +function expectLegacyMissingCredentialsError( + result: { reasonCode?: string; error?: string } | undefined, + reasonCode: string, +) { + expect(result?.reasonCode).toBe(reasonCode); + expect(result?.error?.split("\n")[0]).toBe("Auth profile credentials are missing or expired."); + expect(result?.error).toContain(`[${reasonCode}]`); +} + describe("buildProbeTargets reason codes", () => { beforeEach(() => { mockStore = { @@ -67,52 +96,18 @@ describe("buildProbeTargets reason codes", () => { }); it("reports invalid_expires with a legacy-compatible first error line", async () => { - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:default"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:default"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); - expect(plan.results[0]?.reasonCode).toBe("invalid_expires"); - expect(plan.results[0]?.error?.split("\n")[0]).toBe( - "Auth profile credentials are missing or expired.", - ); - expect(plan.results[0]?.error).toContain("[invalid_expires]"); + expectLegacyMissingCredentialsError(plan.results[0], "invalid_expires"); }); it("reports excluded_by_auth_order when profile id is not present in explicit order", async () => { mockStore.order = { anthropic: ["anthropic:work"], }; - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:work"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:work"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); @@ -137,30 +132,116 @@ describe("buildProbeTargets reason codes", () => { mockAllowedProfiles = ["anthropic:default"]; resolveSecretRefStringMock.mockRejectedValueOnce(new Error("missing secret")); - const plan = await buildProbeTargets({ - cfg: { - auth: { - order: { - anthropic: ["anthropic:default"], - }, - }, - } as OpenClawConfig, - providers: ["anthropic"], - modelCandidates: ["anthropic/claude-sonnet-4-6"], - options: { - timeoutMs: 5_000, - concurrency: 1, - maxTokens: 16, - }, - }); + const plan = await buildAnthropicProbePlan(["anthropic:default"]); expect(plan.targets).toHaveLength(0); expect(plan.results).toHaveLength(1); - expect(plan.results[0]?.reasonCode).toBe("unresolved_ref"); - expect(plan.results[0]?.error?.split("\n")[0]).toBe( - "Auth profile credentials are missing or expired.", - ); - expect(plan.results[0]?.error).toContain("[unresolved_ref]"); + expectLegacyMissingCredentialsError(plan.results[0], "unresolved_ref"); expect(plan.results[0]?.error).toContain("env:default:MISSING_ANTHROPIC_TOKEN"); }); + + it("skips marker-only models.json credentials when building probe targets", async () => { + const previousAnthropic = process.env.ANTHROPIC_API_KEY; + const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_OAUTH_TOKEN; + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + try { + const plan = await buildProbeTargets({ + cfg: { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + api: "anthropic-messages", + apiKey: OLLAMA_LOCAL_AUTH_MARKER, + models: [], + }, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + + expect(plan.targets).toEqual([]); + expect(plan.results).toEqual([]); + } finally { + if (previousAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropic; + } + if (previousAnthropicOauth === undefined) { + delete process.env.ANTHROPIC_OAUTH_TOKEN; + } else { + process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth; + } + } + }); + + it("does not treat arbitrary all-caps models.json apiKey values as markers", async () => { + const previousAnthropic = process.env.ANTHROPIC_API_KEY; + const previousAnthropicOauth = process.env.ANTHROPIC_OAUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_OAUTH_TOKEN; + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + try { + const plan = await buildProbeTargets({ + cfg: { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + api: "anthropic-messages", + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + models: [], + }, + }, + }, + } as OpenClawConfig, + providers: ["anthropic"], + modelCandidates: ["anthropic/claude-sonnet-4-6"], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + + expect(plan.results).toEqual([]); + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0]).toEqual( + expect.objectContaining({ + provider: "anthropic", + source: "models.json", + label: "models.json", + }), + ); + } finally { + if (previousAnthropic === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = previousAnthropic; + } + if (previousAnthropicOauth === undefined) { + delete process.env.ANTHROPIC_OAUTH_TOKEN; + } else { + process.env.ANTHROPIC_OAUTH_TOKEN = previousAnthropicOauth; + } + } + }); }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 8a2ec87adcc..40eb6b99b9b 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -12,6 +12,7 @@ import { resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { describeFailoverError } from "../../agents/failover-error.js"; +import { isNonSecretApiKeyMarker } from "../../agents/model-auth-markers.js"; import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { @@ -373,7 +374,8 @@ export async function buildProbeTargets(params: { const envKey = resolveEnvApiKey(providerKey); const customKey = getCustomProviderApiKey(cfg, providerKey); - if (!envKey && !customKey) { + const hasUsableModelsJsonKey = Boolean(customKey && !isNonSecretApiKeyMarker(customKey)); + if (!envKey && !hasUsableModelsJsonKey) { continue; } diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index a4fd2cdf0f5..187b55176f5 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -94,8 +94,13 @@ function loadAvailableModels(registry: ModelRegistry): Model[] { } } -export async function loadModelRegistry(cfg: OpenClawConfig) { - await ensureOpenClawModelsJson(cfg); +export async function loadModelRegistry( + cfg: OpenClawConfig, + opts?: { sourceConfig?: OpenClawConfig }, +) { + // Persistence must be based on source config (pre-resolution) so SecretRef-managed + // credentials remain markers in models.json for command paths too. + await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); const registry = discoverModels(authStorage, agentDir); diff --git a/src/commands/models/load-config.test.ts b/src/commands/models/load-config.test.ts new file mode 100644 index 00000000000..b8969fd4681 --- /dev/null +++ b/src/commands/models/load-config.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(), + readConfigFileSnapshotForWrite: vi.fn(), + setRuntimeConfigSnapshot: vi.fn(), + resolveCommandSecretRefsViaGateway: vi.fn(), + getModelsCommandSecretTargetIds: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot: mocks.setRuntimeConfigSnapshot, +})); + +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("../../cli/command-secret-targets.js", () => ({ + getModelsCommandSecretTargetIds: mocks.getModelsCommandSecretTargetIds, +})); + +import { loadModelsConfig, loadModelsConfigWithSource } from "./load-config.js"; + +describe("models load-config", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns source+resolved configs and sets runtime snapshot", async () => { + const sourceConfig = { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, // pragma: allowlist secret + }, + }, + }, + }; + const runtimeConfig = { + models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret + }; + const targetIds = new Set(["models.providers.*.apiKey"]); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + }); + mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: ["diag-one", "diag-two"], + }); + + const result = await loadModelsConfigWithSource({ commandName: "models list", runtime }); + + expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({ + config: runtimeConfig, + commandName: "models list", + targetIds, + }); + expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(runtime.log).toHaveBeenNthCalledWith(1, "[secrets] diag-one"); + expect(runtime.log).toHaveBeenNthCalledWith(2, "[secrets] diag-two"); + expect(result).toEqual({ + sourceConfig, + resolvedConfig, + diagnostics: ["diag-one", "diag-two"], + }); + }); + + it("loadModelsConfig returns resolved config while preserving runtime snapshot behavior", async () => { + const sourceConfig = { models: { providers: {} } }; + const runtimeConfig = { + models: { providers: { openai: { apiKey: "sk-runtime" } } }, // pragma: allowlist secret + }; + const resolvedConfig = { + models: { providers: { openai: { apiKey: "sk-resolved" } } }, // pragma: allowlist secret + }; + const targetIds = new Set(["models.providers.*.apiKey"]); + + mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, resolved: sourceConfig }, + writeOptions: {}, + }); + mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [], + }); + + await expect(loadModelsConfig({ commandName: "models list" })).resolves.toBe(resolvedConfig); + expect(mocks.setRuntimeConfigSnapshot).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + }); +}); diff --git a/src/commands/models/load-config.ts b/src/commands/models/load-config.ts index ead48fa8b8a..854cd5240da 100644 --- a/src/commands/models/load-config.ts +++ b/src/commands/models/load-config.ts @@ -1,15 +1,39 @@ import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; -import { loadConfig, type OpenClawConfig } from "../../config/config.js"; +import { + loadConfig, + readConfigFileSnapshotForWrite, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -export async function loadModelsConfig(params: { +export type LoadedModelsConfig = { + sourceConfig: OpenClawConfig; + resolvedConfig: OpenClawConfig; + diagnostics: string[]; +}; + +async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise { + try { + const { snapshot } = await readConfigFileSnapshotForWrite(); + if (snapshot.valid) { + return snapshot.resolved; + } + } catch { + // Fall back to runtime-loaded config if source snapshot cannot be read. + } + return fallback; +} + +export async function loadModelsConfigWithSource(params: { commandName: string; runtime?: RuntimeEnv; -}): Promise { - const loadedRaw = loadConfig(); +}): Promise { + const runtimeConfig = loadConfig(); + const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig); const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ - config: loadedRaw, + config: runtimeConfig, commandName: params.commandName, targetIds: getModelsCommandSecretTargetIds(), }); @@ -18,5 +42,17 @@ export async function loadModelsConfig(params: { params.runtime.log(`[secrets] ${entry}`); } } - return resolvedConfig; + setRuntimeConfigSnapshot(resolvedConfig, sourceConfig); + return { + sourceConfig, + resolvedConfig, + diagnostics, + }; +} + +export async function loadModelsConfig(params: { + commandName: string; + runtime?: RuntimeEnv; +}): Promise { + return (await loadModelsConfigWithSource(params)).resolvedConfig; } diff --git a/src/commands/onboard-auth.config-core.kilocode.test.ts b/src/commands/onboard-auth.config-core.kilocode.test.ts index 4f1ed796520..82faf85c8f0 100644 --- a/src/commands/onboard-auth.config-core.kilocode.test.ts +++ b/src/commands/onboard-auth.config-core.kilocode.test.ts @@ -150,7 +150,7 @@ describe("Kilo Gateway provider config", () => { describe("env var resolution", () => { it("resolves KILOCODE_API_KEY from env", () => { const envSnapshot = captureEnv(["KILOCODE_API_KEY"]); - process.env.KILOCODE_API_KEY = "test-kilo-key"; + process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret try { const result = resolveEnvApiKey("kilocode"); @@ -177,7 +177,7 @@ describe("Kilo Gateway provider config", () => { it("resolves the kilocode api key via resolveApiKeyForProvider", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KILOCODE_API_KEY"]); - process.env.KILOCODE_API_KEY = "kilo-provider-test-key"; + process.env.KILOCODE_API_KEY = "kilo-provider-test-key"; // pragma: allowlist secret try { const auth = await resolveApiKeyForProvider({ diff --git a/src/commands/onboard-auth.credentials.test.ts b/src/commands/onboard-auth.credentials.test.ts index 94661933152..5ff2c57461d 100644 --- a/src/commands/onboard-auth.credentials.test.ts +++ b/src/commands/onboard-auth.credentials.test.ts @@ -94,7 +94,7 @@ describe("onboard auth credentials secret refs", () => { envValue: "sk-moonshot-env", profileId: "moonshot:default", apply: async (agentDir) => { - await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" }); + await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret }, expected: { keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" }, @@ -136,10 +136,10 @@ describe("onboard auth credentials secret refs", () => { it("preserves cloudflare metadata when storing keyRef", async () => { const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-cloudflare-"); lifecycle.setStateDir(env.stateDir); - process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret"; + process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret"; // pragma: allowlist secret await setCloudflareAiGatewayConfig("account-1", "gateway-1", "cf-secret", env.agentDir, { - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }); const parsed = await readAuthProfilesForAgent<{ @@ -175,7 +175,7 @@ describe("onboard auth credentials secret refs", () => { envValue: "sk-openai-env", profileId: "openai:default", apply: async (agentDir) => { - await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" }); + await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret }, expected: { keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, @@ -187,11 +187,11 @@ describe("onboard auth credentials secret refs", () => { it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => { const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-volc-byte-"); lifecycle.setStateDir(env.stateDir); - process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret"; - process.env.BYTEPLUS_API_KEY = "byteplus-secret"; + process.env.VOLCANO_ENGINE_API_KEY = "volcengine-secret"; // pragma: allowlist secret + process.env.BYTEPLUS_API_KEY = "byteplus-secret"; // pragma: allowlist secret - await setVolcengineApiKey("volcengine-secret", env.agentDir, { secretInputMode: "ref" }); - await setByteplusApiKey("byteplus-secret", env.agentDir, { secretInputMode: "ref" }); + await setVolcengineApiKey("volcengine-secret", env.agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret + await setByteplusApiKey("byteplus-secret", env.agentDir, { secretInputMode: "ref" }); // pragma: allowlist secret const parsed = await readAuthProfilesForAgent<{ profiles?: Record; diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 3774c699da1..a79eb1d970a 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -420,7 +420,7 @@ describe("applyMinimaxApiConfig", () => { providers: { anthropic: { baseUrl: "https://api.anthropic.com", - apiKey: "anthropic-key", + apiKey: "anthropic-key", // pragma: allowlist secret api: "anthropic-messages", models: [ { diff --git a/src/commands/onboard-config.test.ts b/src/commands/onboard-config.test.ts index 076f98a02f1..c5997345fe7 100644 --- a/src/commands/onboard-config.test.ts +++ b/src/commands/onboard-config.test.ts @@ -7,6 +7,10 @@ import { } from "./onboard-config.js"; describe("applyOnboardingLocalWorkspaceConfig", () => { + it("defaults local onboarding tool profile to coding", () => { + expect(ONBOARDING_DEFAULT_TOOLS_PROFILE).toBe("coding"); + }); + it("sets secure dmScope default when unset", () => { const baseConfig: OpenClawConfig = {}; const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace"); diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index f2ae8991141..62b1006283e 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -3,7 +3,7 @@ import type { DmScope } from "../config/types.base.js"; import type { ToolProfileId } from "../config/types.tools.js"; export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer"; -export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "messaging"; +export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "coding"; export function applyOnboardingLocalWorkspaceConfig( baseConfig: OpenClawConfig, diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 1d9e8bc5881..c5d29a12177 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -145,7 +145,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }>(configPath); expect(cfg?.agents?.defaults?.workspace).toBe(workspace); - expect(cfg?.tools?.profile).toBe("messaging"); + expect(cfg?.tools?.profile).toBe("coding"); expect(cfg?.gateway?.auth?.mode).toBe("token"); expect(cfg?.gateway?.auth?.token).toBe(token); }); diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index e1f77bfff68..69995eef3d7 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -34,6 +34,44 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): { return { prompter, notes }; } +function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConfig { + return { + tools: { + web: { + search: { + provider: "perplexity", + ...(enabled === undefined ? {} : { enabled }), + perplexity: { apiKey }, + }, + }, + }, + }; +} + +async function runBlankPerplexityKeyEntry( + apiKey: string, + enabled?: boolean, +): Promise { + const cfg = createPerplexityConfig(apiKey, enabled); + const { prompter } = createPrompter({ + selectValue: "perplexity", + textValue: "", + }); + return setupSearch(cfg, runtime, prompter); +} + +async function runQuickstartPerplexitySetup( + apiKey: string, + enabled?: boolean, +): Promise<{ result: OpenClawConfig; prompter: WizardPrompter }> { + const cfg = createPerplexityConfig(apiKey, enabled); + const { prompter } = createPrompter({ selectValue: "perplexity" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + return { result, prompter }; +} + describe("setupSearch", () => { it("returns config unchanged when user skips", async () => { const cfg: OpenClawConfig = {}; @@ -103,74 +141,49 @@ describe("setupSearch", () => { }); it("shows missing-key note when no key is provided and no env var", async () => { - const cfg: OpenClawConfig = {}; - const { prompter, notes } = createPrompter({ - selectValue: "brave", - textValue: "", - }); - const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.provider).toBe("brave"); - expect(result.tools?.web?.search?.enabled).toBeUndefined(); - const missingNote = notes.find((n) => n.message.includes("No API key stored")); - expect(missingNote).toBeDefined(); + const original = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + try { + const cfg: OpenClawConfig = {}; + const { prompter, notes } = createPrompter({ + selectValue: "brave", + textValue: "", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + const missingNote = notes.find((n) => n.message.includes("No API key stored")); + expect(missingNote).toBeDefined(); + } finally { + if (original === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = original; + } + } }); it("keeps existing key when user leaves input blank", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { apiKey: "existing-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ - selectValue: "perplexity", - textValue: "", - }); - const result = await setupSearch(cfg, runtime, prompter); + const result = await runBlankPerplexityKeyEntry( + "existing-key", // pragma: allowlist secret + ); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); expect(result.tools?.web?.search?.enabled).toBe(true); }); it("advanced preserves enabled:false when keeping existing key", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - enabled: false, - perplexity: { apiKey: "existing-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ - selectValue: "perplexity", - textValue: "", - }); - const result = await setupSearch(cfg, runtime, prompter); + const result = await runBlankPerplexityKeyEntry( + "existing-key", // pragma: allowlist secret + false, + ); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); expect(result.tools?.web?.search?.enabled).toBe(false); }); it("quickstart skips key prompt when config key exists", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - perplexity: { apiKey: "stored-pplx-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { - quickstartDefaults: true, - }); + const { result, prompter } = await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + ); expect(result.tools?.web?.search?.provider).toBe("perplexity"); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(true); @@ -178,21 +191,10 @@ describe("setupSearch", () => { }); it("quickstart preserves enabled:false when search was intentionally disabled", async () => { - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "perplexity", - enabled: false, - perplexity: { apiKey: "stored-pplx-key" }, // pragma: allowlist secret - }, - }, - }, - }; - const { prompter } = createPrompter({ selectValue: "perplexity" }); - const result = await setupSearch(cfg, runtime, prompter, { - quickstartDefaults: true, - }); + const { result, prompter } = await runQuickstartPerplexitySetup( + "stored-pplx-key", // pragma: allowlist secret + false, + ); expect(result.tools?.web?.search?.provider).toBe("perplexity"); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(false); @@ -200,14 +202,24 @@ describe("setupSearch", () => { }); it("quickstart falls through to key prompt when no key and no env var", async () => { - const cfg: OpenClawConfig = {}; - const { prompter } = createPrompter({ selectValue: "grok", textValue: "" }); - const result = await setupSearch(cfg, runtime, prompter, { - quickstartDefaults: true, - }); - expect(prompter.text).toHaveBeenCalled(); - expect(result.tools?.web?.search?.provider).toBe("grok"); - expect(result.tools?.web?.search?.enabled).toBeUndefined(); + const original = process.env.XAI_API_KEY; + delete process.env.XAI_API_KEY; + try { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "grok", textValue: "" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(prompter.text).toHaveBeenCalled(); + expect(result.tools?.web?.search?.provider).toBe("grok"); + expect(result.tools?.web?.search?.enabled).toBeUndefined(); + } finally { + if (original === undefined) { + delete process.env.XAI_API_KEY; + } else { + process.env.XAI_API_KEY = original; + } + } }); it("quickstart skips key prompt when env var is available", async () => { diff --git a/src/commands/status-all/channels.mattermost-token-summary.test.ts b/src/commands/status-all/channels.mattermost-token-summary.test.ts index 3c028ba44d1..a797d028d9f 100644 --- a/src/commands/status-all/channels.mattermost-token-summary.test.ts +++ b/src/commands/status-all/channels.mattermost-token-summary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import { makeDirectPlugin } from "../../test-utils/channel-plugin-test-fixtures.js"; import { buildChannelsTable } from "./channels.js"; vi.mock("../../channels/plugins/index.js", () => ({ @@ -117,16 +118,10 @@ function makeUnavailableSlackPlugin(): ChannelPlugin { } function makeSourceAwareUnavailablePlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "Slack", + docsPath: "/channels/slack", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -161,10 +156,7 @@ function makeSourceAwareUnavailablePlugin(): ChannelPlugin { isConfigured: (account) => Boolean((account as { configured?: boolean }).configured), isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin { @@ -214,16 +206,10 @@ function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin { } function makeHttpSlackUnavailablePlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "slack", - meta: { - id: "slack", - label: "Slack", - selectionLabel: "Slack", - docsPath: "/channels/slack", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "Slack", + docsPath: "/channels/slack", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -255,23 +241,14 @@ function makeHttpSlackUnavailablePlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } function makeTokenPlugin(): ChannelPlugin { - return { + return makeDirectPlugin({ id: "token-only", - meta: { - id: "token-only", - label: "TokenOnly", - selectionLabel: "TokenOnly", - docsPath: "/channels/token-only", - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, + label: "TokenOnly", + docsPath: "/channels/token-only", config: { listAccountIds: () => ["primary"], defaultAccountId: () => "primary", @@ -283,10 +260,7 @@ function makeTokenPlugin(): ChannelPlugin { isConfigured: () => true, isEnabled: () => true, }, - actions: { - listActions: () => ["send"], - }, - }; + }); } describe("buildChannelsTable - mattermost token summary", () => { diff --git a/src/commands/zai-endpoint-detect.test.ts b/src/commands/zai-endpoint-detect.test.ts index ce2d45fc044..292ee7ac761 100644 --- a/src/commands/zai-endpoint-detect.test.ts +++ b/src/commands/zai-endpoint-detect.test.ts @@ -58,7 +58,7 @@ describe("detectZaiEndpoint", () => { for (const scenario of scenarios) { const detected = await detectZaiEndpoint({ - apiKey: "sk-test", + apiKey: "sk-test", // pragma: allowlist secret fetchFn: makeFetch(scenario.responses), }); diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 6d25e4c6d16..92a4769c1fd 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -154,6 +154,35 @@ describe("config identity defaults", () => { }); }); + it("accepts SecretRef values in model provider headers", async () => { + await withTempHome("openclaw-config-identity-", async (home) => { + const cfg = await writeAndLoadConfig(home, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }, + }, + models: [], + }, + }, + }, + }); + + expect(cfg.models?.providers?.openai?.headers?.Authorization).toEqual({ + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", + }); + }); + }); + it("respects empty responsePrefix to disable identity defaults", async () => { await withTempHome("openclaw-config-identity-", async (home) => { const cfg = await writeAndLoadConfig(home, configWithDefaultIdentity({ responsePrefix: "" })); diff --git a/src/config/config.ts b/src/config/config.ts index 5d5dd4ad32b..2c7d6a75f1b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -3,6 +3,7 @@ export { clearRuntimeConfigSnapshot, createConfigIO, getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, readBestEffortConfig, parseConfigJson5, diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 5bb57d2ab93..d0b65565e41 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -16,7 +16,7 @@ describe("web search provider config", () => { enabled: true, provider: "perplexity", providerConfig: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret }, }), ); @@ -30,7 +30,7 @@ describe("web search provider config", () => { enabled: true, provider: "gemini", providerConfig: { - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret model: "gemini-2.5-flash", }, }), @@ -75,57 +75,57 @@ describe("web search provider auto-detection", () => { }); it("auto-detects brave when only BRAVE_API_KEY is set", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("brave"); }); it("auto-detects gemini when only GEMINI_API_KEY is set", () => { - process.env.GEMINI_API_KEY = "test-gemini-key"; + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("gemini"); }); it("auto-detects kimi when only KIMI_API_KEY is set", () => { - process.env.KIMI_API_KEY = "test-kimi-key"; + process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); }); it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => { - process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("perplexity"); }); it("auto-detects grok when only XAI_API_KEY is set", () => { - process.env.XAI_API_KEY = "test-xai-key"; + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("grok"); }); it("auto-detects kimi when only KIMI_API_KEY is set", () => { - process.env.KIMI_API_KEY = "test-kimi-key"; + process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); }); it("auto-detects kimi when only MOONSHOT_API_KEY is set", () => { - process.env.MOONSHOT_API_KEY = "test-moonshot-key"; + process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("kimi"); }); it("follows priority order — perplexity wins when multiple keys available", () => { - process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; - process.env.BRAVE_API_KEY = "test-brave-key"; - process.env.GEMINI_API_KEY = "test-gemini-key"; - process.env.XAI_API_KEY = "test-xai-key"; + process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("perplexity"); }); it("brave wins over gemini and grok when perplexity unavailable", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; - process.env.GEMINI_API_KEY = "test-gemini-key"; - process.env.XAI_API_KEY = "test-xai-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret + process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret + process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("brave"); }); it("explicit provider always wins regardless of keys", () => { - process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret expect( resolveSearchProvider({ provider: "gemini" } as unknown as Parameters< typeof resolveSearchProvider diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index 0a37de08aaa..e8820ad1d9c 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -5,6 +5,7 @@ import { withTempHome } from "./home-env.test-harness.js"; import { clearConfigCache, clearRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, loadConfig, setRuntimeConfigSnapshot, writeConfigFile, @@ -12,6 +13,70 @@ import { import type { OpenClawConfig } from "./types.js"; describe("runtime config snapshot writes", () => { + it("returns the source snapshot when runtime snapshot is active", async () => { + await withTempHome("openclaw-config-runtime-source-", async () => { + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret + 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", // pragma: allowlist secret + 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"); @@ -31,7 +96,7 @@ describe("runtime config snapshot writes", () => { providers: { openai: { baseUrl: "https://api.openai.com/v1", - apiKey: "sk-runtime-resolved", + apiKey: "sk-runtime-resolved", // pragma: allowlist secret models: [], }, }, diff --git a/src/config/io.ts b/src/config/io.ts index dc010d258b8..7d0ab9ed3bb 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1345,6 +1345,10 @@ export function getRuntimeConfigSnapshot(): OpenClawConfig | null { return runtimeConfigSnapshot; } +export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { + return runtimeConfigSourceSnapshot; +} + export function loadConfig(): OpenClawConfig { if (runtimeConfigSnapshot) { return runtimeConfigSnapshot; diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index d6728858af8..30efe8451d2 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -111,7 +111,7 @@ describe("applyModelDefaults", () => { providers: { anthropic: { baseUrl: "https://relay.example.com/api", - apiKey: "cr_xxxx", + apiKey: "cr_xxxx", // pragma: allowlist secret models: [ { id: "claude-opus-4-6", diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 3abaea37f44..e173be34ec8 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -120,7 +120,7 @@ describe("redactConfigSnapshot", () => { serviceAccount: { type: "service_account", client_email: "bot@example.iam.gserviceaccount.com", - private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----", + private_key: "-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----", // pragma: allowlist secret }, }, }, @@ -259,7 +259,7 @@ describe("redactConfigSnapshot", () => { const config = { gateway: { mode: "local", - auth: { password: "local" }, + auth: { password: "local" }, // pragma: allowlist secret }, }; const snapshot = makeSnapshot(config, JSON.stringify(config)); @@ -299,7 +299,7 @@ describe("redactConfigSnapshot", () => { it("handles overlap fallback and SecretRef in the same snapshot", () => { const config = { - gateway: { mode: "default", auth: { password: "default" } }, + gateway: { mode: "default", auth: { password: "default" } }, // pragma: allowlist secret models: { providers: { default: { @@ -780,7 +780,7 @@ describe("redactConfigSnapshot", () => { }; const snapshot = makeSnapshot({ env: { - GROQ_API_KEY: "gsk-secret-123", + GROQ_API_KEY: "gsk-secret-123", // pragma: allowlist secret NODE_ENV: "production", }, }); @@ -803,7 +803,7 @@ describe("redactConfigSnapshot", () => { entries: { web_search: { env: { - GEMINI_API_KEY: "gemini-secret-456", + GEMINI_API_KEY: "gemini-secret-456", // pragma: allowlist secret BRAVE_REGION: "us", }, }, @@ -828,14 +828,14 @@ describe("redactConfigSnapshot", () => { const hints = mainSchemaHints; const snapshot = makeSnapshot({ env: { - GROQ_API_KEY: "gsk-contract-123", + GROQ_API_KEY: "gsk-contract-123", // pragma: allowlist secret NODE_ENV: "production", }, skills: { entries: { web_search: { env: { - GEMINI_API_KEY: "gemini-contract-456", + GEMINI_API_KEY: "gemini-contract-456", // pragma: allowlist secret BRAVE_REGION: "us", }, }, diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index ab685448fdf..f660af8831e 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -775,6 +775,9 @@ describe("config help copy quality", () => { it("documents auth/model root semantics and provider secret handling", () => { const providerKey = FIELD_HELP["models.providers.*.apiKey"]; expect(/secret|env|credential/i.test(providerKey)).toBe(true); + const modelsMode = FIELD_HELP["models.mode"]; + expect(modelsMode.includes("SecretRef-managed")).toBe(true); + expect(modelsMode.includes("preserve")).toBe(true); const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"]; expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f13944cb127..f0d30c854e7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -688,7 +688,7 @@ export const FIELD_HELP: Record = { models: "Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.", "models.mode": - 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json apiKey/baseUrl values and fall back to config when agent values are empty or missing; matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', + 'Controls provider catalog behavior: "merge" keeps built-ins and overlays your custom providers, while "replace" uses only your configured providers. In "merge", matching provider IDs preserve non-empty agent models.json baseUrl values, while apiKey values are preserved only when the provider is not SecretRef-managed in current config/auth-profile context; SecretRef-managed providers refresh apiKey from current source markers, and matching model contextWindow/maxTokens use the higher value between explicit and implicit entries.', "models.providers": "Provider map keyed by provider ID containing connection/auth settings and concrete model definitions. Use stable provider keys so references from agents and tooling remain portable across environments.", "models.providers.*.baseUrl": diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 41ac8b1aa5d..e21a330f2e6 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -135,6 +135,7 @@ describe("mapSensitivePaths", () => { expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true); expect(hints["channels.googlechat.serviceAccount"]?.sensitive).toBe(true); expect(hints["gateway.auth.token"]?.sensitive).toBe(true); + expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); }); diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index 1157fb1834f..f61bdc7e924 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -6,6 +6,9 @@ import { withEnvAsync } from "../test-utils/env.js"; import { createConfigIO } from "./io.js"; import { normalizeTalkSection } from "./talk.js"; +const envVar = (...parts: string[]) => parts.join("_"); +const elevenLabsApiKeyEnv = ["ELEVENLABS_API", "KEY"].join("_"); + async function withTempConfig( config: unknown, run: (configPath: string) => Promise, @@ -24,10 +27,10 @@ describe("talk normalization", () => { it("maps legacy ElevenLabs fields into provider/providers", () => { const normalized = normalizeTalkSection({ voiceId: "voice-123", - voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, + voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, // pragma: allowlist secret modelId: "eleven_v3", outputFormat: "pcm_44100", - apiKey: "secret-key", + apiKey: "secret-key", // pragma: allowlist secret interruptOnSpeech: false, }); @@ -39,14 +42,14 @@ describe("talk normalization", () => { voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, modelId: "eleven_v3", outputFormat: "pcm_44100", - apiKey: "secret-key", + apiKey: "secret-key", // pragma: allowlist secret }, }, voiceId: "voice-123", voiceAliases: { Clawd: "EXAVITQu4vr4xnSDxMaL" }, modelId: "eleven_v3", outputFormat: "pcm_44100", - apiKey: "secret-key", + apiKey: "secret-key", // pragma: allowlist secret interruptOnSpeech: false, }); }); @@ -98,7 +101,9 @@ describe("talk normalization", () => { }); it("merges ELEVENLABS_API_KEY into normalized defaults for legacy configs", async () => { - await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + // pragma: allowlist secret + const elevenLabsApiKey = "env-eleven-key"; // pragma: allowlist secret + await withEnvAsync({ [elevenLabsApiKeyEnv]: elevenLabsApiKey }, async () => { await withTempConfig( { talk: { @@ -110,15 +115,16 @@ describe("talk normalization", () => { const snapshot = await io.readConfigFileSnapshot(); expect(snapshot.config.talk?.provider).toBe("elevenlabs"); expect(snapshot.config.talk?.providers?.elevenlabs?.voiceId).toBe("voice-123"); - expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe("env-eleven-key"); - expect(snapshot.config.talk?.apiKey).toBe("env-eleven-key"); + expect(snapshot.config.talk?.providers?.elevenlabs?.apiKey).toBe(elevenLabsApiKey); + expect(snapshot.config.talk?.apiKey).toBe(elevenLabsApiKey); }, ); }); }); it("does not apply ELEVENLABS_API_KEY when active provider is not elevenlabs", async () => { - await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + const elevenLabsApiKey = "env-eleven-key"; // pragma: allowlist secret + await withEnvAsync({ [elevenLabsApiKeyEnv]: elevenLabsApiKey }, async () => { await withTempConfig( { talk: { @@ -143,7 +149,7 @@ describe("talk normalization", () => { }); it("does not inject ELEVENLABS_API_KEY fallback when talk.apiKey is SecretRef", async () => { - await withEnvAsync({ ELEVENLABS_API_KEY: "env-eleven-key" }, async () => { + await withEnvAsync({ [envVar("ELEVENLABS", "API", "KEY")]: "env-eleven-key" }, async () => { await withTempConfig( { talk: { diff --git a/src/config/telegram-webhook-port.test.ts b/src/config/telegram-webhook-port.test.ts index 80fdf3a5ce8..f2ffce5419b 100644 --- a/src/config/telegram-webhook-port.test.ts +++ b/src/config/telegram-webhook-port.test.ts @@ -7,7 +7,7 @@ describe("Telegram webhookPort config", () => { channels: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: 8787, }, }, @@ -20,7 +20,7 @@ describe("Telegram webhookPort config", () => { channels: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: 0, }, }, @@ -33,7 +33,7 @@ describe("Telegram webhookPort config", () => { channels: { telegram: { webhookUrl: "https://example.com/telegram-webhook", - webhookSecret: "secret", + webhookSecret: "secret", // pragma: allowlist secret webhookPort: -1, }, }, diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 4ef646cc48a..b881269d961 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -54,7 +54,7 @@ export type ModelProviderConfig = { auth?: ModelProviderAuthMode; api?: ModelApi; injectNumCtxForOpenAICompat?: boolean; - headers?: Record; + headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 733917e4dac..7ddef789282 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -234,7 +234,7 @@ export const ModelProviderSchema = z .optional(), api: ModelApiSchema.optional(), injectNumCtxForOpenAICompat: z.boolean().optional(), - headers: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), }) diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index bfd751664af..e78f251dc8b 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -52,8 +52,7 @@ type TurnOptions = { storeEntries?: Record>; }; -/** Like runTurn but does NOT assert the embedded agent was called (for error paths). */ -async function runErrorTurn(home: string, options: TurnOptions = {}) { +async function runTurnCore(home: string, options: TurnOptions = {}) { const storePath = await writeSessionStoreEntries(home, { "agent:main:main": { sessionId: "main-session", @@ -80,36 +79,17 @@ async function runErrorTurn(home: string, options: TurnOptions = {}) { lane: "cron", }); + return res; +} + +/** Like runTurn but does NOT assert the embedded agent was called (for error paths). */ +async function runErrorTurn(home: string, options: TurnOptions = {}) { + const res = await runTurnCore(home, options); return { res }; } async function runTurn(home: string, options: TurnOptions = {}) { - const storePath = await writeSessionStoreEntries(home, { - "agent:main:main": { - sessionId: "main-session", - updatedAt: Date.now(), - lastProvider: "webchat", - lastTo: "", - }, - ...options.storeEntries, - }); - mockEmbeddedOk(); - - const jobPayload = options.jobPayload ?? { - kind: "agentTurn" as const, - message: DEFAULT_MESSAGE, - deliver: false, - }; - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, options.cfgOverrides), - deps: makeDeps(), - job: makeJob(jobPayload), - message: DEFAULT_MESSAGE, - sessionKey: options.sessionKey ?? "cron:job-1", - lane: "cron", - }); - + const res = await runTurnCore(home, options); return { res, call: lastEmbeddedCall() }; } diff --git a/src/cron/isolated-agent/run.interim-retry.test.ts b/src/cron/isolated-agent/run.interim-retry.test.ts index 19f47bc8411..90d663ed020 100644 --- a/src/cron/isolated-agent/run.interim-retry.test.ts +++ b/src/cron/isolated-agent/run.interim-retry.test.ts @@ -17,6 +17,21 @@ const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); describe("runCronIsolatedAgentTurn — interim ack retry", () => { setupRunCronIsolatedAgentTurnSuite(); + const mockFallbackPassthrough = () => { + runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { + const result = await run(provider, model); + return { result, provider, model, attempts: [] }; + }); + }; + + const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => { + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); + expect(result.status).toBe("ok"); + expect(runWithModelFallbackMock).toHaveBeenCalledTimes(expectedFallbackCalls); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(expectedAgentCalls); + return result; + }; + const usePayloadTextExtraction = () => { pickLastNonEmptyTextFromPayloadsMock.mockImplementation( (payloads?: Array<{ text?: string }>) => { @@ -47,16 +62,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(2); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); + mockFallbackPassthrough(); + await runTurnAndExpectOk(2, 2); expect(runEmbeddedPiAgentMock.mock.calls[1]?.[0]?.prompt).toContain( "previous response was only an acknowledgement", ); @@ -69,16 +76,8 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { meta: { agentMeta: { usage: { input: 10, output: 20 } } }, }); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + mockFallbackPassthrough(); + await runTurnAndExpectOk(1, 1); }); it("does not retry when descendants were spawned in this run even if they already settled", async () => { @@ -94,15 +93,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => { ]); countActiveDescendantRunsMock.mockReturnValue(0); - runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => { - const result = await run(provider, model); - return { result, provider, model, attempts: [] }; - }); - - const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams()); - - expect(result.status).toBe("ok"); - expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + mockFallbackPassthrough(); + await runTurnAndExpectOk(1, 1); }); }); diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index e62e9e2e7ab..b0cf8778eb1 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -30,6 +30,22 @@ function resolveCachedCron(expr: string, timezone: string): Cron { return next; } +function resolveCronFromSchedule(schedule: { + tz?: string; + expr?: unknown; + cron?: unknown; +}): Cron | undefined { + const exprSource = typeof schedule.expr === "string" ? schedule.expr : schedule.cron; + if (typeof exprSource !== "string") { + throw new Error("invalid cron schedule: expr is required"); + } + const expr = exprSource.trim(); + if (!expr) { + return undefined; + } + return resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); +} + export function coerceFiniteScheduleNumber(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; @@ -81,16 +97,10 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe return anchor + steps * everyMs; } - const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; - const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; - if (typeof exprSource !== "string") { - throw new Error("invalid cron schedule: expr is required"); - } - const expr = exprSource.trim(); - if (!expr) { + const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown }); + if (!cron) { return undefined; } - const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); let next = cron.nextRun(new Date(nowMs)); if (!next) { return undefined; @@ -132,16 +142,10 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n if (schedule.kind !== "cron") { return undefined; } - const cronSchedule = schedule as { expr?: unknown; cron?: unknown }; - const exprSource = typeof cronSchedule.expr === "string" ? cronSchedule.expr : cronSchedule.cron; - if (typeof exprSource !== "string") { - throw new Error("invalid cron schedule: expr is required"); - } - const expr = exprSource.trim(); - if (!expr) { + const cron = resolveCronFromSchedule(schedule as { tz?: string; expr?: unknown; cron?: unknown }); + if (!cron) { return undefined; } - const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); const previousRuns = cron.previousRuns(1, new Date(nowMs)); const previous = previousRuns[0]; if (!previous) { diff --git a/src/cron/service.issue-13992-regression.test.ts b/src/cron/service.issue-13992-regression.test.ts index f3ee7121a70..698724b3143 100644 --- a/src/cron/service.issue-13992-regression.test.ts +++ b/src/cron/service.issue-13992-regression.test.ts @@ -46,21 +46,14 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const now = Date.now(); const pastDue = now - 60_000; - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", + const job = createCronSystemEventJob(now, { createdAtMs: now - 3600_000, updatedAtMs: now - 3600_000, state: { nextRunAtMs: pastDue, lastRunAtMs: pastDue + 1000, }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state, { recomputeExpired: true }); @@ -73,21 +66,14 @@ describe("issue #13992 regression - cron jobs skip execution", () => { const now = Date.now(); const pastDue = now - 60_000; - const job: CronJob = { - id: "test-job", - name: "test job", - enabled: true, - schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, - payload: { kind: "systemEvent", text: "test" }, - sessionTarget: "main", - wakeMode: "next-heartbeat", + const job = createCronSystemEventJob(now, { createdAtMs: now - 3600_000, updatedAtMs: now - 3600_000, state: { nextRunAtMs: pastDue, runningAtMs: now - 500, }, - }; + }); const state = createMockCronStateForJobs({ jobs: [job], nowMs: now }); recomputeNextRunsForMaintenance(state, { recomputeExpired: true }); diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 9fc8283b84a..b080302a644 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -17,6 +17,38 @@ import { stopSystemdService, } from "./systemd.js"; +type ExecFileError = Error & { + stderr?: string; + code?: string | number; +}; + +const createExecFileError = ( + message: string, + options: { stderr?: string; code?: string | number } = {}, +): ExecFileError => { + const err = new Error(message) as ExecFileError; + err.code = options.code ?? 1; + if (options.stderr) { + err.stderr = options.stderr; + } + return err; +}; + +const createWritableStreamMock = () => { + const write = vi.fn(); + return { + write, + stdout: { write } as unknown as NodeJS.WritableStream, + }; +}; + +const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { + const { write, stdout } = createWritableStreamMock(); + await restartSystemdService({ stdout, env }); + expect(write).toHaveBeenCalledTimes(1); + expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); +}; + describe("systemd availability", () => { beforeEach(() => { execFileMock.mockReset(); @@ -46,15 +78,10 @@ describe("systemd availability", () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "status"]); - const err = new Error( - "Failed to connect to user scope bus via local transport", - ) as Error & { - stderr?: string; - code?: number; - }; - err.stderr = - "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; - err.code = 1; + const err = createExecFileError("Failed to connect to user scope bus via local transport", { + stderr: + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined", + }); cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { @@ -271,6 +298,10 @@ describe("parseSystemdExecStart", () => { }); describe("systemd service control", () => { + const assertMachineRestartArgs = (args: string[]) => { + expect(args).toEqual(["--machine", "debian@", "--user", "restart", "openclaw-gateway.service"]); + }; + beforeEach(() => { execFileMock.mockReset(); }); @@ -298,13 +329,7 @@ describe("systemd service control", () => { expect(args).toEqual(["--user", "restart", "openclaw-gateway-work.service"]); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { OPENCLAW_PROFILE: "work" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ OPENCLAW_PROFILE: "work" }); }); it("surfaces stop failures with systemctl detail", async () => { @@ -331,22 +356,10 @@ describe("systemd service control", () => { cb(null, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual([ - "--machine", - "debian@", - "--user", - "restart", - "openclaw-gateway.service", - ]); + assertMachineRestartArgs(args); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { SUDO_USER: "debian" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ SUDO_USER: "debian" }); }); it("keeps direct --user scope when SUDO_USER is root", async () => { @@ -359,26 +372,17 @@ describe("systemd service control", () => { expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { SUDO_USER: "root", USER: "root" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ SUDO_USER: "root", USER: "root" }); }); it("falls back to machine user scope for restart when user bus env is missing", async () => { execFileMock .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "status"]); - const err = new Error("Failed to connect to user scope bus") as Error & { - stderr?: string; - code?: number; - }; - err.stderr = - "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; - err.code = 1; + const err = createExecFileError("Failed to connect to user scope bus", { + stderr: + "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined", + }); cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { @@ -387,30 +391,15 @@ describe("systemd service control", () => { }) .mockImplementationOnce((_cmd, args, _opts, cb) => { expect(args).toEqual(["--user", "restart", "openclaw-gateway.service"]); - const err = new Error("Failed to connect to user scope bus") as Error & { - stderr?: string; - code?: number; - }; - err.stderr = "Failed to connect to user scope bus"; - err.code = 1; + const err = createExecFileError("Failed to connect to user scope bus", { + stderr: "Failed to connect to user scope bus", + }); cb(err, "", ""); }) .mockImplementationOnce((_cmd, args, _opts, cb) => { - expect(args).toEqual([ - "--machine", - "debian@", - "--user", - "restart", - "openclaw-gateway.service", - ]); + assertMachineRestartArgs(args); cb(null, "", ""); }); - const write = vi.fn(); - const stdout = { write } as unknown as NodeJS.WritableStream; - - await restartSystemdService({ stdout, env: { USER: "debian" } }); - - expect(write).toHaveBeenCalledTimes(1); - expect(String(write.mock.calls[0]?.[0])).toContain("Restarted systemd service"); + await assertRestartSuccess({ USER: "debian" }); }); }); diff --git a/src/discord/monitor/message-handler.bot-self-filter.test.ts b/src/discord/monitor/message-handler.bot-self-filter.test.ts index 7f5b2276987..4358301b92d 100644 --- a/src/discord/monitor/message-handler.bot-self-filter.test.ts +++ b/src/discord/monitor/message-handler.bot-self-filter.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + DEFAULT_DISCORD_BOT_USER_ID, + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); @@ -15,53 +18,12 @@ vi.mock("./message-handler.process.js", () => ({ const { createDiscordMessageHandler } = await import("./message-handler.js"); -const BOT_USER_ID = "bot-123"; - -function createHandlerParams(overrides?: Partial<{ botUserId: string }>) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: overrides?.botUserId ?? BOT_USER_ID, - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - }; -} - function createMessageData(authorId: string, channelId = "ch-1") { return { - author: { id: authorId, bot: authorId === BOT_USER_ID }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, message: { id: "msg-1", - author: { id: authorId, bot: authorId === BOT_USER_ID }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, content: "hello", channel_id: channelId, }, @@ -70,26 +32,7 @@ function createMessageData(authorId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { - return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, - }; + return createDiscordPreflightContext(channelId); } describe("createDiscordMessageHandler bot-self filter", () => { @@ -97,10 +40,10 @@ describe("createDiscordMessageHandler bot-self filter", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect( - handler(createMessageData(BOT_USER_ID) as never, {} as never), + handler(createMessageData(DEFAULT_DISCORD_BOT_USER_ID) as never, {} as never), ).resolves.toBeUndefined(); expect(preflightDiscordMessageMock).not.toHaveBeenCalled(); @@ -115,7 +58,7 @@ describe("createDiscordMessageHandler bot-self filter", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect( handler(createMessageData("user-456") as never, {} as never), diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index ac2ab57e283..1e4d9c5dddb 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -27,6 +27,13 @@ type DiscordConfig = NonNullable< type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; type DiscordClient = import("@buape/carbon").Client; +const DEFAULT_CFG = { + session: { + mainKey: "main", + scope: "per-sender", + }, +} as import("../../config/config.js").OpenClawConfig; + function createThreadBinding( overrides?: Partial< import("../../infra/outbound/session-binding-service.js").SessionBindingRecord @@ -82,6 +89,154 @@ function createPreflightArgs(params: { }; } +function createGuildTextClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { + return { + fetchChannel: async (channelId: string) => { + if (channelId === params.threadId) { + return { + id: params.threadId, + type: ChannelType.PublicThread, + name: "focus", + parentId: params.parentId, + ownerId: "owner-1", + }; + } + if (channelId === params.parentId) { + return { + id: params.parentId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +function createGuildEvent(params: { + channelId: string; + guildId: string; + author: import("@buape/carbon").Message["author"]; + message: import("@buape/carbon").Message; +}): DiscordMessageEvent { + return { + channel_id: params.channelId, + guild_id: params.guildId, + guild: { + id: params.guildId, + name: "Guild One", + }, + author: params.author, + message: params.message, + } as unknown as DiscordMessageEvent; +} + +function createMessage(params: { + id: string; + channelId: string; + content: string; + author: { + id: string; + bot: boolean; + username?: string; + }; + mentionedUsers?: Array<{ id: string }>; + mentionedEveryone?: boolean; + attachments?: Array>; +}): import("@buape/carbon").Message { + return { + id: params.id, + content: params.content, + timestamp: new Date().toISOString(), + channelId: params.channelId, + attachments: params.attachments ?? [], + mentionedUsers: params.mentionedUsers ?? [], + mentionedRoles: [], + mentionedEveryone: params.mentionedEveryone ?? false, + author: params.author, + } as unknown as import("@buape/carbon").Message; +} + +async function runThreadBoundPreflight(params: { + threadId: string; + parentId: string; + message: import("@buape/carbon").Message; + threadBinding: import("../../infra/outbound/session-binding-service.js").SessionBindingRecord; + discordConfig: DiscordConfig; + registerBindingAdapter?: boolean; +}) { + if (params.registerBindingAdapter) { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === params.threadId ? params.threadBinding : null, + }); + } + + const client = createThreadClient({ + threadId: params.threadId, + parentId: params.parentId, + }); + + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: DEFAULT_CFG, + discordConfig: params.discordConfig, + data: createGuildEvent({ + channelId: params.threadId, + guildId: "guild-1", + author: params.message.author, + message: params.message, + }), + client, + }), + threadBindings: { + getByThreadId: (id: string) => (id === params.threadId ? params.threadBinding : undefined), + } as import("./thread-bindings.js").ThreadBindingManager, + }); +} + +async function runGuildPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; + discordConfig: DiscordConfig; + cfg?: import("../../config/config.js").OpenClawConfig; + guildEntries?: Parameters[0]["guildEntries"]; +}) { + return preflightDiscordMessage({ + ...createPreflightArgs({ + cfg: params.cfg ?? DEFAULT_CFG, + discordConfig: params.discordConfig, + data: createGuildEvent({ + channelId: params.channelId, + guildId: params.guildId, + author: params.message.author, + message: params.message, + }), + client: createGuildTextClient(params.channelId), + }), + guildEntries: params.guildEntries, + }); +} + describe("resolvePreflightMentionRequirement", () => { it("requires mention when config requires mention and thread is not bound", () => { expect( @@ -124,81 +279,26 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-system-1"; const parentId = "channel-parent-1"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-system-1", + channelId: threadId, content: "⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.", - timestamp: new Date().toISOString(), - channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "relay-bot-1", bot: true, username: "OpenClaw", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, discordConfig: { allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: { - getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), - } as import("./thread-bindings.js").ThreadBindingManager, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).toBeNull(); @@ -211,87 +311,26 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-bot-regular-1"; const parentId = "channel-parent-regular-1"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-regular-1", - content: "here is tool output chunk", - timestamp: new Date().toISOString(), channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "here is tool output chunk", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; - - registerSessionBindingAdapter({ - channel: "discord", - accountId: "default", - listBySession: () => [], - resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null), }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runThreadBoundPreflight({ + threadId, + parentId, + message, + threadBinding, discordConfig: { allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: { - getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined), - } as import("./thread-bindings.js").ThreadBindingManager, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, + registerBindingAdapter: true, }); expect(result).not.toBeNull(); @@ -302,42 +341,17 @@ describe("preflightDiscordMessage", () => { const threadBinding = createThreadBinding(); const threadId = "thread-bot-focus"; const parentId = "channel-parent-focus"; - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === threadId) { - return { - id: threadId, - type: ChannelType.PublicThread, - name: "focus", - parentId, - ownerId: "owner-1", - }; - } - if (channelId === parentId) { - return { - id: parentId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const client = createThreadClient({ threadId, parentId }); + const message = createMessage({ id: "m-bot-1", - content: "relay message without mention", - timestamp: new Date().toISOString(), channelId: threadId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "relay message without mention", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); registerSessionBindingAdapter({ channel: "discord", @@ -349,24 +363,17 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, + ...DEFAULT_CFG, } as import("../../config/config.js").OpenClawConfig, discordConfig: { allowBots: true, } as DiscordConfig, - data: { - channel_id: threadId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, + data: createGuildEvent({ + channelId: threadId, + guildId: "guild-1", author: message.author, message, - } as unknown as DiscordMessageEvent, + }), client, }), ); @@ -379,69 +386,24 @@ describe("preflightDiscordMessage", () => { it("drops bot messages without mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-off"; const guildId = "guild-bot-mentions-off"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-mentions-off", - content: "relay chatter", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, + content: "relay chatter", author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runGuildPreflight({ + channelId, + guildId, + message, discordConfig: { allowBots: "mentions", - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).toBeNull(); @@ -450,69 +412,25 @@ describe("preflightDiscordMessage", () => { it("allows bot messages with explicit mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-on"; const guildId = "guild-bot-mentions-on"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-bot-mentions-on", - content: "hi <@openclaw-bot>", - timestamp: new Date().toISOString(), channelId, - attachments: [], + content: "hi <@openclaw-bot>", mentionedUsers: [{ id: "openclaw-bot" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + const result = await runGuildPreflight({ + channelId, + guildId, + message, discordConfig: { allowBots: "mentions", - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, + } as DiscordConfig, }); expect(result).not.toBeNull(); @@ -521,75 +439,29 @@ describe("preflightDiscordMessage", () => { it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-other-mention-1", - content: "hello <@999>", - timestamp: new Date().toISOString(), channelId, - attachments: [], + content: "hello <@999>", mentionedUsers: [{ id: "999" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + const result = await runGuildPreflight({ + channelId, + guildId, + message, + discordConfig: {} as DiscordConfig, guildEntries: { [guildId]: { requireMention: false, ignoreOtherMentions: true, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).toBeNull(); @@ -598,75 +470,29 @@ describe("preflightDiscordMessage", () => { it("does not drop @everyone messages when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-everyone"; const guildId = "guild-other-mention-everyone"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const message = createMessage({ id: "m-other-mention-everyone", - content: "@everyone heads up", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], + content: "@everyone heads up", mentionedEveryone: true, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); - const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: {} as NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] - >["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + const result = await runGuildPreflight({ + channelId, + guildId, + message, + discordConfig: {} as DiscordConfig, guildEntries: { [guildId]: { requireMention: false, ignoreOtherMentions: true, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).not.toBeNull(); @@ -676,74 +502,38 @@ describe("preflightDiscordMessage", () => { it("ignores bot-sent @everyone mentions for detection", async () => { const channelId = "channel-everyone-1"; const guildId = "guild-everyone-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; - const message = { + const client = createGuildTextClient(channelId); + const message = createMessage({ id: "m-everyone-1", - content: "@everyone heads up", - timestamp: new Date().toISOString(), channelId, - attachments: [], - mentionedUsers: [], - mentionedRoles: [], + content: "@everyone heads up", mentionedEveryone: true, author: { id: "relay-bot-1", bot: true, username: "Relay", }, - } as unknown as import("@buape/carbon").Message; + }); const result = await preflightDiscordMessage({ - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, - discordConfig: { - allowBots: true, - } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), + ...createPreflightArgs({ + cfg: DEFAULT_CFG, + discordConfig: { + allowBots: true, + } as DiscordConfig, + data: createGuildEvent({ + channelId, + guildId, + author: message.author, + message, + }), + client, + }), guildEntries: { [guildId]: { requireMention: false, }, }, - data: { - channel_id: channelId, - guild_id: guildId, - guild: { - id: guildId, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, }); expect(result).not.toBeNull(); @@ -754,24 +544,12 @@ describe("preflightDiscordMessage", () => { transcribeFirstAudioMock.mockResolvedValue("hey openclaw"); const channelId = "channel-audio-1"; - const client = { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; + const client = createGuildTextClient(channelId); - const message = { + const message = createMessage({ id: "m-audio-1", - content: "", - timestamp: new Date().toISOString(), channelId, + content: "", attachments: [ { id: "att-1", @@ -780,23 +558,17 @@ describe("preflightDiscordMessage", () => { filename: "voice.ogg", }, ], - mentionedUsers: [], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "Alice", }, - } as unknown as import("@buape/carbon").Message; + }); const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, + ...DEFAULT_CFG, messages: { groupChat: { mentionPatterns: ["openclaw"], @@ -804,16 +576,12 @@ describe("preflightDiscordMessage", () => { }, } as import("../../config/config.js").OpenClawConfig, discordConfig: {} as DiscordConfig, - data: { - channel_id: channelId, - guild_id: "guild-1", - guild: { - id: "guild-1", - name: "Guild One", - }, + data: createGuildEvent({ + channelId, + guildId: "guild-1", author: message.author, message, - } as unknown as DiscordMessageEvent, + }), client, }), ); diff --git a/src/discord/monitor/message-handler.queue.test.ts b/src/discord/monitor/message-handler.queue.test.ts index 45fbfeee278..122ce852333 100644 --- a/src/discord/monitor/message-handler.queue.test.ts +++ b/src/discord/monitor/message-handler.queue.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn()); +type SetStatusFn = (patch: Record) => void; vi.mock("./message-handler.preflight.js", () => ({ preflightDiscordMessage: preflightDiscordMessageMock, @@ -24,52 +27,6 @@ function createDeferred() { return { promise, resolve }; } -function createHandlerParams(overrides?: { - setStatus?: (patch: Record) => void; - abortSignal?: AbortSignal; - workerRunTimeoutMs?: number; -}) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "bot-123", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2_000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - setStatus: overrides?.setStatus, - abortSignal: overrides?.abortSignal, - workerRunTimeoutMs: overrides?.workerRunTimeoutMs, - }; -} - function createMessageData(messageId: string, channelId = "ch-1") { return { channel_id: channelId, @@ -85,25 +42,43 @@ function createMessageData(messageId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { + return createDiscordPreflightContext(channelId); +} + +async function createLifecycleStopScenario(params: { + createHandler: (status: SetStatusFn) => { + handler: (data: never, opts: never) => Promise; + stop: () => void; + }; +}) { + const runInFlight = createDeferred(); + processDiscordMessageMock.mockImplementation(async () => { + await runInFlight.promise; + }); + preflightDiscordMessageMock.mockImplementation( + async (contextParams: { data: { channel_id: string } }) => + createPreflightContext(contextParams.data.channel_id), + ); + + const setStatus = vi.fn(); + const { handler, stop } = params.createHandler(setStatus); + + await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + + const callsBeforeStop = setStatus.mock.calls.length; + stop(); + return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, + setStatus, + callsBeforeStop, + finish: async () => { + runInFlight.resolve(); + await runInFlight.promise; + await Promise.resolve(); }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, }; } @@ -113,7 +88,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processDiscordMessageMock.mockReset(); const setStatus = vi.fn(); - createDiscordMessageHandler(createHandlerParams({ setStatus })); + createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); expect(setStatus).toHaveBeenCalledWith( expect.objectContaining({ @@ -142,7 +117,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); @@ -205,7 +180,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 50 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); const handler = createDiscordMessageHandler(params); await expect( @@ -256,7 +231,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 0 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 }); const handler = createDiscordMessageHandler(params); await expect( @@ -305,7 +280,7 @@ describe("createDiscordMessageHandler queue behavior", () => { try { const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect( handler(createMessageData("m-1") as never, {} as never), ).resolves.toBeUndefined(); @@ -342,67 +317,35 @@ describe("createDiscordMessageHandler queue behavior", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const abortController = new AbortController(); - const handler = createDiscordMessageHandler( - createHandlerParams({ setStatus, abortSignal: abortController.signal }), - ); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const abortController = new AbortController(); + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status, abortSignal: abortController.signal }), + ); + return { handler, stop: () => abortController.abort() }; + }, }); - const callsBeforeAbort = setStatus.mock.calls.length; - abortController.abort(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeAbort); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("stops status publishing after handler deactivation", async () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status }), + ); + return { handler, stop: () => handler.deactivate() }; + }, }); - const callsBeforeDeactivate = setStatus.mock.calls.length; - handler.deactivate(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeDeactivate); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("skips queued runs that have not started yet after deactivation", async () => { @@ -420,7 +363,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await vi.waitFor(() => { expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); @@ -460,7 +403,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processedMessageIds.push(ctx.messageId ?? "unknown"); }); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); const sequentialDispatch = (async () => { await handler(createMessageData("m-1") as never, {} as never); @@ -499,7 +442,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); diff --git a/src/discord/monitor/message-handler.test-helpers.ts b/src/discord/monitor/message-handler.test-helpers.ts new file mode 100644 index 00000000000..6084fc1a00e --- /dev/null +++ b/src/discord/monitor/message-handler.test-helpers.ts @@ -0,0 +1,76 @@ +import { vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.js"; +import type { createDiscordMessageHandler } from "./message-handler.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +export const DEFAULT_DISCORD_BOT_USER_ID = "bot-123"; + +export function createDiscordHandlerParams(overrides?: { + botUserId?: string; + setStatus?: (patch: Record) => void; + abortSignal?: AbortSignal; + workerRunTimeoutMs?: number; +}): Parameters[0] { + const cfg: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: "test-token", + groupPolicy: "allowlist", + }, + }, + messages: { + inbound: { + debounceMs: 0, + }, + }, + }; + return { + cfg, + discordConfig: cfg.channels?.discord, + accountId: "default", + token: "test-token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: overrides?.botUserId ?? DEFAULT_DISCORD_BOT_USER_ID, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2_000, + replyToMode: "off" as const, + dmEnabled: true, + groupDmEnabled: false, + threadBindings: createNoopThreadBindingManager("default"), + setStatus: overrides?.setStatus, + abortSignal: overrides?.abortSignal, + workerRunTimeoutMs: overrides?.workerRunTimeoutMs, + }; +} + +export function createDiscordPreflightContext(channelId = "ch-1") { + return { + data: { + channel_id: channelId, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + }, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + route: { + sessionKey: `agent:main:discord:channel:${channelId}`, + }, + baseSessionKey: `agent:main:discord:channel:${channelId}`, + messageChannelId: channelId, + }; +} diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/src/discord/monitor/native-command.commands-allowfrom.test.ts new file mode 100644 index 00000000000..e5757846b94 --- /dev/null +++ b/src/discord/monitor/native-command.commands-allowfrom.test.ts @@ -0,0 +1,245 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; +import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import * as pluginCommandsModule from "../../plugins/commands.js"; +import { createDiscordNativeCommand } from "./native-command.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +type MockCommandInteraction = { + user: { id: string; username: string; globalName: string }; + channel: { type: ChannelType; id: string }; + guild: { id: string; name?: string } | null; + rawData: { id: string; member: { roles: string[] } }; + options: { + getString: ReturnType; + getNumber: ReturnType; + getBoolean: ReturnType; + }; + reply: ReturnType; + followUp: ReturnType; + client: object; +}; + +function createInteraction(params?: { + userId?: string; + channelId?: string; + guildId?: string; + guildName?: string; +}): MockCommandInteraction { + return { + user: { + id: params?.userId ?? "123456789012345678", + username: "discord-user", + globalName: "Discord User", + }, + channel: { + type: ChannelType.GuildText, + id: params?.channelId ?? "234567890123456789", + }, + guild: { + id: params?.guildId ?? "345678901234567890", + name: params?.guildName ?? "Test Guild", + }, + rawData: { + id: "interaction-1", + member: { roles: [] }, + }, + options: { + getString: vi.fn().mockReturnValue(null), + getNumber: vi.fn().mockReturnValue(null), + getBoolean: vi.fn().mockReturnValue(null), + }, + reply: vi.fn().mockResolvedValue({ ok: true }), + followUp: vi.fn().mockResolvedValue({ ok: true }), + client: {}, + }; +} + +function createConfig(): OpenClawConfig { + return { + commands: { + allowFrom: { + discord: ["user:123456789012345678"], + }, + }, + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "234567890123456789": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; +} + +function createCommand(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"), + }); +} + +describe("Discord native slash commands with commands.allowFrom", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("authorizes guild slash commands when commands.allowFrom.discord matches the sender", async () => { + const cfg = createConfig(); + const command = createCommand(cfg); + const interaction = createInteraction(); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ content: "You are not authorized to use this command." }), + ); + }); + + it("authorizes guild slash commands from the global commands.allowFrom list when provider-specific allowFrom is missing", async () => { + const cfg = createConfig(); + cfg.commands = { + allowFrom: { + "*": ["user:123456789012345678"], + }, + }; + const command = createCommand(cfg); + const interaction = createInteraction(); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ content: "You are not authorized to use this command." }), + ); + }); + + it("authorizes guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord matches the sender", async () => { + const cfg = createConfig(); + cfg.commands = { + ...cfg.commands, + useAccessGroups: false, + }; + const command = createCommand(cfg); + const interaction = createInteraction(); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ content: "You are not authorized to use this command." }), + ); + }); + + it("rejects guild slash commands when commands.allowFrom.discord does not match the sender", async () => { + const cfg = createConfig(); + const command = createCommand(cfg); + const interaction = createInteraction({ userId: "999999999999999999" }); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: "You are not authorized to use this command.", + ephemeral: true, + }), + ); + }); + + it("rejects guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord does not match the sender", async () => { + const cfg = createConfig(); + cfg.commands = { + ...cfg.commands, + useAccessGroups: false, + }; + const command = createCommand(cfg); + const interaction = createInteraction({ userId: "999999999999999999" }); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: "You are not authorized to use this command.", + ephemeral: true, + }), + ); + }); +}); diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index 1e98f349e63..291c6d45bba 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -87,6 +87,75 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } +function createStatusCommand(cfg: OpenClawConfig) { + const commandSpec: NativeCommandSpec = { + name: "status", + description: "Status", + acceptsArgs: false, + }; + return createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); +} + +function setConfiguredBinding(channelId: string, boundSessionKey: string) { + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ + spec: { + channel: "discord", + accountId: "default", + conversationId: channelId, + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: `config:acp:discord:default:${channelId}`, + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "discord", + accountId: "default", + conversationId: channelId, + }, + status: "active", + boundAt: 0, + }, + }); + persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ + ok: true, + sessionKey: boundSessionKey, + }); +} + +function createDispatchSpy() { + return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); +} + +function expectBoundSessionDispatch( + dispatchSpy: ReturnType, + boundSessionKey: string, +) { + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { + ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; + }; + expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); + expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); +} + describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -169,20 +238,7 @@ describe("Discord native plugin command dispatch", () => { }, ], } as OpenClawConfig; - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; - const command = createDiscordNativeCommand({ - command: commandSpec, - cfg, - discordConfig: cfg.channels?.discord ?? {}, - accountId: "default", - sessionPrefix: "discord:slash", - ephemeralDefault: true, - threadBindings: createNoopThreadBindingManager("default"), - }); + const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId, @@ -190,53 +246,14 @@ describe("Discord native plugin command dispatch", () => { guildName: "Ops", }); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "discord", - accountId: "default", - conversationId: channelId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:discord:default:1478836151241412759", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: "default", - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + setConfiguredBinding(channelId, boundSessionKey); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); + const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { - ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; - }; - expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); - expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); it("routes Discord DM native slash commands through configured ACP bindings", async () => { @@ -266,71 +283,19 @@ describe("Discord native plugin command dispatch", () => { }, }, } as OpenClawConfig; - const commandSpec: NativeCommandSpec = { - name: "status", - description: "Status", - acceptsArgs: false, - }; - const command = createDiscordNativeCommand({ - command: commandSpec, - cfg, - discordConfig: cfg.channels?.discord ?? {}, - accountId: "default", - sessionPrefix: "discord:slash", - ephemeralDefault: true, - threadBindings: createNoopThreadBindingManager("default"), - }); + const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.DM, channelId, }); - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "discord", - accountId: "default", - conversationId: channelId, - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:discord:default:dm-1", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "discord", - accountId: "default", - conversationId: channelId, - }, - status: "active", - boundAt: 0, - }, - }); - persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ - ok: true, - sessionKey: boundSessionKey, - }); + setConfiguredBinding(channelId, boundSessionKey); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); + const dispatchSpy = createDispatchSpy(); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { - ctx?: { SessionKey?: string; CommandTargetSessionKey?: string }; - }; - expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey); - expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); + expectBoundSessionDispatch(dispatchSpy, boundSessionKey); }); }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 18960697a40..401c30f01fe 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -20,6 +20,7 @@ import { } from "../../acp/persistent-bindings.route.js"; import { resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { resolveCommandAuthorization } from "../../auto-reply/command-auth.js"; import type { ChatCommandDefinition, CommandArgDefinition, @@ -92,6 +93,46 @@ import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; const log = createSubsystemLogger("discord/native-command"); +function resolveDiscordNativeCommandAllowlistAccess(params: { + cfg: OpenClawConfig; + accountId?: string | null; + sender: { id: string; name?: string; tag?: string }; + chatType: "direct" | "group" | "thread" | "channel"; + conversationId?: string; +}) { + const commandsAllowFrom = params.cfg.commands?.allowFrom; + if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") { + return { configured: false, allowed: false } as const; + } + const configured = + Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]); + if (!configured) { + return { configured: false, allowed: false } as const; + } + + const from = + params.chatType === "direct" + ? `discord:${params.sender.id}` + : `discord:${params.chatType}:${params.conversationId ?? "unknown"}`; + const auth = resolveCommandAuthorization({ + ctx: { + Provider: "discord", + Surface: "discord", + OriginatingChannel: "discord", + AccountId: params.accountId ?? undefined, + ChatType: params.chatType, + From: from, + SenderId: params.sender.id, + SenderUsername: params.sender.name, + SenderTag: params.sender.tag, + }, + cfg: params.cfg, + // We only want explicit commands.allowFrom authorization here. + commandAuthorized: false, + }); + return { configured: true, allowed: auth.isAuthorizedSender } as const; +} + function buildDiscordCommandOptions(params: { command: ChatCommandDefinition; cfg: ReturnType; @@ -1297,6 +1338,23 @@ async function dispatchDiscordCommandInteraction(params: { }, allowNameMatching, }); + const commandsAllowFromAccess = resolveDiscordNativeCommandAllowlistAccess({ + cfg, + accountId, + sender: { + id: sender.id, + name: sender.name, + tag: sender.tag, + }, + chatType: isDirectMessage + ? "direct" + : isThreadChannel + ? "thread" + : interaction.guild + ? "channel" + : "group", + conversationId: rawChannelId || undefined, + }); const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, guildEntries: discordConfig?.guilds, @@ -1418,10 +1476,20 @@ async function dispatchDiscordCommandInteraction(params: { }); const authorizers = useAccessGroups ? [ + { + configured: commandsAllowFromAccess.configured, + allowed: commandsAllowFromAccess.allowed, + }, { configured: ownerAllowList != null, allowed: ownerOk }, { configured: hasAccessRestrictions, allowed: memberAllowed }, ] - : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + : [ + { + configured: commandsAllowFromAccess.configured, + allowed: commandsAllowFromAccess.allowed, + }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ]; commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers, diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts index 191156b7d97..70fa4f74aa3 100644 --- a/src/discord/resolve-channels.test.ts +++ b/src/discord/resolve-channels.test.ts @@ -4,9 +4,11 @@ import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; describe("resolveDiscordChannelAllowlist", () => { + type DiscordChannel = { id: string; name: string; guild_id: string; type: number }; + async function resolveWithChannelLookup(params: { guilds: Array<{ id: string; name: string }>; - channel: { id: string; name: string; guild_id: string; type: number }; + channel: DiscordChannel; entry: string; }) { const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { @@ -26,6 +28,44 @@ describe("resolveDiscordChannelAllowlist", () => { }); } + async function resolveGuild111Entry2024(params: { + channelLookup: () => Response; + guildChannels?: DiscordChannel[]; + }) { + const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url.endsWith("/users/@me/guilds")) { + return jsonResponse([{ id: "111", name: "Test Server" }]); + } + if (url.endsWith("/channels/2024")) { + return params.channelLookup(); + } + if (url.endsWith("/guilds/111/channels")) { + return jsonResponse( + params.guildChannels ?? [ + { id: "c1", name: "2024", guild_id: "111", type: 0 }, + { id: "c2", name: "general", guild_id: "111", type: 0 }, + ], + ); + } + return new Response("not found", { status: 404 }); + }); + + return resolveDiscordChannelAllowlist({ + token: "test", + entries: ["111/2024"], + fetcher, + }); + } + + function expectUnresolved1112024( + res: Awaited>, + ) { + expect(res[0]?.resolved).toBe(false); + expect(res[0]?.channelId).toBe("2024"); + expect(res[0]?.guildId).toBe("111"); + } + it("resolves guild/channel by name", async () => { const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { const url = urlToString(input); @@ -210,27 +250,8 @@ describe("resolveDiscordChannelAllowlist", () => { }); it("falls back to name matching when numeric channel name is not a valid ID", async () => { - const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url.endsWith("/users/@me/guilds")) { - return jsonResponse([{ id: "111", name: "Test Server" }]); - } - if (url.endsWith("/channels/2024")) { - return new Response("not found", { status: 404 }); - } - if (url.endsWith("/guilds/111/channels")) { - return jsonResponse([ - { id: "c1", name: "2024", guild_id: "111", type: 0 }, - { id: "c2", name: "general", guild_id: "111", type: 0 }, - ]); - } - return new Response("not found", { status: 404 }); - }); - - const res = await resolveDiscordChannelAllowlist({ - token: "test", - entries: ["111/2024"], - fetcher, + const res = await resolveGuild111Entry2024({ + channelLookup: () => new Response("not found", { status: 404 }), }); expect(res[0]?.resolved).toBe(true); @@ -240,58 +261,20 @@ describe("resolveDiscordChannelAllowlist", () => { }); it("does not fall back to name matching when channel lookup returns 403", async () => { - const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url.endsWith("/users/@me/guilds")) { - return jsonResponse([{ id: "111", name: "Test Server" }]); - } - if (url.endsWith("/channels/2024")) { - return new Response("Missing Access", { status: 403 }); - } - if (url.endsWith("/guilds/111/channels")) { - return jsonResponse([ - { id: "c1", name: "2024", guild_id: "111", type: 0 }, - { id: "c2", name: "general", guild_id: "111", type: 0 }, - ]); - } - return new Response("not found", { status: 404 }); + const res = await resolveGuild111Entry2024({ + channelLookup: () => new Response("Missing Access", { status: 403 }), }); - const res = await resolveDiscordChannelAllowlist({ - token: "test", - entries: ["111/2024"], - fetcher, - }); - - expect(res[0]?.resolved).toBe(false); - expect(res[0]?.channelId).toBe("2024"); - expect(res[0]?.guildId).toBe("111"); + expectUnresolved1112024(res); }); it("does not fall back to name matching when channel payload is malformed", async () => { - const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => { - const url = urlToString(input); - if (url.endsWith("/users/@me/guilds")) { - return jsonResponse([{ id: "111", name: "Test Server" }]); - } - if (url.endsWith("/channels/2024")) { - return jsonResponse({ id: "2024", name: "unknown", type: 0 }); - } - if (url.endsWith("/guilds/111/channels")) { - return jsonResponse([{ id: "c1", name: "2024", guild_id: "111", type: 0 }]); - } - return new Response("not found", { status: 404 }); + const res = await resolveGuild111Entry2024({ + channelLookup: () => jsonResponse({ id: "2024", name: "unknown", type: 0 }), + guildChannels: [{ id: "c1", name: "2024", guild_id: "111", type: 0 }], }); - const res = await resolveDiscordChannelAllowlist({ - token: "test", - entries: ["111/2024"], - fetcher, - }); - - expect(res[0]?.resolved).toBe(false); - expect(res[0]?.channelId).toBe("2024"); - expect(res[0]?.guildId).toBe("111"); + expectUnresolved1112024(res); }); it("resolves guild: prefixed id as guild (not channel)", async () => { diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index df2848f0f67..813cc62edce 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -175,7 +175,7 @@ describe("docker-setup.sh", () => { const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); expect(envFile).toContain("OPENCLAW_DOCKER_APT_PACKAGES=ffmpeg build-essential"); expect(envFile).toContain("OPENCLAW_EXTRA_MOUNTS="); - expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); + expect(envFile).toContain("OPENCLAW_HOME_VOLUME=openclaw-home"); // pragma: allowlist secret const extraCompose = await readFile( join(activeSandbox.rootDir, "docker-compose.extra.yml"), "utf8", @@ -247,7 +247,7 @@ describe("docker-setup.sh", () => { expect(result.status).toBe(0); const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); - expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); + expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); // pragma: allowlist secret }); it("treats OPENCLAW_SANDBOX=0 as disabled", async () => { diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 81b0dbcaeda..803d22a181b 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -125,7 +125,7 @@ describe("gateway auth", () => { resolveGatewayAuth({ authConfig: { token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }, env: { OPENCLAW_GATEWAY_TOKEN: "env-token", @@ -134,7 +134,7 @@ describe("gateway auth", () => { }), ).toMatchObject({ token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }); }); @@ -174,7 +174,7 @@ describe("gateway auth", () => { it("marks mode source as override when runtime mode override is provided", () => { expect( resolveGatewayAuth({ - authConfig: { mode: "password", password: "config-password" }, + authConfig: { mode: "password", password: "config-password" }, // pragma: allowlist secret authOverride: { mode: "token" }, env: {} as NodeJS.ProcessEnv, }), @@ -182,7 +182,7 @@ describe("gateway auth", () => { mode: "token", modeSource: "override", token: undefined, - password: "config-password", + password: "config-password", // pragma: allowlist secret }); }); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7ab4cf7b231..850bf008cbd 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -635,7 +635,7 @@ describe("callGateway password resolution", () => { const explicitAuthCases = [ { label: "password", - authKey: "password", + authKey: "password", // pragma: allowlist secret envKey: "OPENCLAW_GATEWAY_PASSWORD", envValue: "from-env", configValue: "from-config", @@ -643,7 +643,7 @@ describe("callGateway password resolution", () => { }, { label: "token", - authKey: "token", + authKey: "token", // pragma: allowlist secret envKey: "OPENCLAW_GATEWAY_TOKEN", envValue: "env-token", configValue: "local-token", @@ -721,7 +721,7 @@ describe("callGateway password resolution", () => { }); it("resolves gateway.auth.password SecretInput refs for gateway calls", async () => { - process.env.LOCAL_REF_PASSWORD = "resolved-local-ref-password"; + process.env.LOCAL_REF_PASSWORD = "resolved-local-ref-password"; // pragma: allowlist secret loadConfig.mockReturnValue({ gateway: { mode: "local", @@ -866,7 +866,7 @@ describe("callGateway password resolution", () => { }); it("resolves gateway.remote.password SecretInput refs when remote password is required", async () => { - process.env.REMOTE_REF_PASSWORD = "resolved-remote-ref-password"; + process.env.REMOTE_REF_PASSWORD = "resolved-remote-ref-password"; // pragma: allowlist secret loadConfig.mockReturnValue({ gateway: { mode: "remote", @@ -898,7 +898,7 @@ describe("callGateway password resolution", () => { remote: { url: "wss://remote.example:18789", token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }, }, secrets: { diff --git a/src/gateway/channel-health-policy.test.ts b/src/gateway/channel-health-policy.test.ts index a4645a13e75..1509bccc4ba 100644 --- a/src/gateway/channel-health-policy.test.ts +++ b/src/gateway/channel-health-policy.test.ts @@ -174,7 +174,7 @@ describe("evaluateChannelHealth", () => { }, { channelId: "slack", - now: 100_000, + now: 75_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, }, @@ -194,13 +194,33 @@ describe("evaluateChannelHealth", () => { }, { channelId: "slack", - now: 100_000, + now: 75_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, }, ); expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); }); + + it("flags inherited event timestamps after the lifecycle exceeds the stale threshold", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 50_000, + lastEventAt: 10_000, + }, + { + channelId: "slack", + now: 140_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" }); + }); }); describe("resolveChannelRestartReason", () => { diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index d8374d04ba8..b3bc74bfc4d 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -109,7 +109,11 @@ export function evaluateChannelHealth( snapshot.lastEventAt != null ) { if (lastStartAt != null && snapshot.lastEventAt < lastStartAt) { - return { healthy: true, reason: "healthy" }; + const lifecycleEventGap = Math.max(0, policy.now - lastStartAt); + if (lifecycleEventGap <= policy.staleEventThresholdMs) { + return { healthy: true, reason: "healthy" }; + } + return { healthy: false, reason: "stale-socket" }; } const eventAge = policy.now - snapshot.lastEventAt; if (eventAge > policy.staleEventThresholdMs) { diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index c69cbef39ee..0d2346efb85 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -123,7 +123,7 @@ function createClientWithIdentity( ) { const identity: DeviceIdentity = { deviceId, - privateKeyPem: "private-key", + privateKeyPem: "private-key", // pragma: allowlist secret publicKeyPem: "public-key", }; return new GatewayClient({ @@ -329,7 +329,7 @@ describe("GatewayClient close handling", () => { const onClose = vi.fn(); const identity: DeviceIdentity = { deviceId: "dev-5", - privateKeyPem: "private-key", + privateKeyPem: "private-key", // pragma: allowlist secret publicKeyPem: "public-key", }; const client = new GatewayClient({ diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index db54f31796c..f723c3fdcb5 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -86,34 +86,36 @@ describe("GatewayClient", () => { }, 4000); test("rejects mismatched tls fingerprint", async () => { - const key = `-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb -DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ -Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk -UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1 -EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s -XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr -FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt -KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval -YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9 -KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl -vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm -MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+ -fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+ -iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh -bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn -aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/ -LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK -gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j -4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+ -42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj -7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2 -bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD -ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy -l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq -YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O -++pfnSCVCyp/TxSkhEDEawU= ------END PRIVATE KEY-----`; + const key = [ + "-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb", + "DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ", + "Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk", + "UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1", + "EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s", + "XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr", + "FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt", + "KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval", + "YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9", + "KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl", + "vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm", + "MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+", + "fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+", + "iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh", + "bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn", + "aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/", + "LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK", + "gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j", + "4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+", + "42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj", + "7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2", + "bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD", + "ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy", + "l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq", + "YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O", + "++pfnSCVCyp/TxSkhEDEawU=", + "-----END PRIVATE KEY-----", + ].join("\n"); const cert = `-----BEGIN CERTIFICATE----- MIIDCTCCAfGgAwIBAgIUel0Lv05cjrViyI/H3tABBJxM7NgwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDEyMDEyMjEzMloXDTI2MDEy diff --git a/src/gateway/credential-precedence.parity.test.ts b/src/gateway/credential-precedence.parity.test.ts index 99a893fcb83..ee85de49b8b 100644 --- a/src/gateway/credential-precedence.parity.test.ts +++ b/src/gateway/credential-precedence.parity.test.ts @@ -20,8 +20,8 @@ type TestCase = { }; const gatewayEnv = { - OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_TOKEN: "env-token", // pragma: allowlist secret + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv; function makeRemoteGatewayConfig(remote: { token?: string; password?: string }): OpenClawConfig { @@ -31,7 +31,7 @@ function makeRemoteGatewayConfig(remote: { token?: string; password?: string }): remote, auth: { token: "local-token", - password: "local-password", + password: "local-password", // pragma: allowlist secret }, }, } as OpenClawConfig; @@ -77,46 +77,46 @@ describe("gateway credential precedence parity", () => { mode: "local", auth: { token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }, }, } as OpenClawConfig, env: { - OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_TOKEN: "env-token", // pragma: allowlist secret + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, expected: { - call: { token: "env-token", password: "env-password" }, - probe: { token: "env-token", password: "env-password" }, - status: { token: "env-token", password: "env-password" }, - auth: { token: "config-token", password: "config-password" }, + call: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + probe: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + status: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + auth: { token: "config-token", password: "config-password" }, // pragma: allowlist secret }, }, { name: "remote mode with remote token configured", cfg: makeRemoteGatewayConfig({ token: "remote-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }), env: gatewayEnv, expected: { - call: { token: "remote-token", password: "env-password" }, - probe: { token: "remote-token", password: "env-password" }, - status: { token: "remote-token", password: "env-password" }, - auth: { token: "local-token", password: "local-password" }, + call: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret + probe: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret + status: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret + auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret }, }, { name: "remote mode without remote token keeps remote probe/status strict", cfg: makeRemoteGatewayConfig({ - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }), env: gatewayEnv, expected: { - call: { token: "env-token", password: "env-password" }, - probe: { token: undefined, password: "env-password" }, - status: { token: undefined, password: "env-password" }, - auth: { token: "local-token", password: "local-password" }, + call: { token: "env-token", password: "env-password" }, // pragma: allowlist secret + probe: { token: undefined, password: "env-password" }, // pragma: allowlist secret + status: { token: undefined, password: "env-password" }, // pragma: allowlist secret + auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret }, }, { @@ -128,11 +128,11 @@ describe("gateway credential precedence parity", () => { }, } as OpenClawConfig, env: { - CLAWDBOT_GATEWAY_TOKEN: "legacy-token", - CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", + CLAWDBOT_GATEWAY_TOKEN: "legacy-token", // pragma: allowlist secret + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, expected: { - call: { token: "legacy-token", password: "legacy-password" }, + call: { token: "legacy-token", password: "legacy-password" }, // pragma: allowlist secret probe: { token: undefined, password: undefined }, status: { token: undefined, password: undefined }, auth: { token: undefined, password: undefined }, diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 3af265e10f5..d9b3fa26783 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -12,11 +12,11 @@ function cfg(input: Partial): OpenClawConfig { type ResolveFromConfigInput = Parameters[0]; type GatewayConfig = NonNullable; -const DEFAULT_GATEWAY_AUTH = { token: "config-token", password: "config-password" }; -const DEFAULT_REMOTE_AUTH = { token: "remote-token", password: "remote-password" }; +const DEFAULT_GATEWAY_AUTH = { token: "config-token", password: "config-password" }; // pragma: allowlist secret +const DEFAULT_REMOTE_AUTH = { token: "remote-token", password: "remote-password" }; // pragma: allowlist secret const DEFAULT_GATEWAY_ENV = { OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv; function resolveGatewayCredentialsFor( @@ -33,7 +33,7 @@ function resolveGatewayCredentialsFor( function expectEnvGatewayCredentials(resolved: { token?: string; password?: string }) { expect(resolved).toEqual({ token: "env-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); } @@ -78,12 +78,12 @@ describe("resolveGatewayCredentialsFromConfig", () => { auth: DEFAULT_GATEWAY_AUTH, }, { - explicitAuth: { token: "explicit-token", password: "explicit-password" }, + explicitAuth: { token: "explicit-token", password: "explicit-password" }, // pragma: allowlist secret }, ); expect(resolved).toEqual({ token: "explicit-token", - password: "explicit-password", + password: "explicit-password", // pragma: allowlist secret }); }); @@ -125,7 +125,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { cfg: cfg({ gateway: { mode: "local", - remote: { token: "remote-token", password: "remote-password" }, + remote: { token: "remote-token", password: "remote-password" }, // pragma: allowlist secret auth: {}, }, }), @@ -134,7 +134,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); expect(resolved).toEqual({ token: "remote-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }); }); @@ -223,8 +223,8 @@ describe("resolveGatewayCredentialsFromConfig", () => { cfg: cfg({ gateway: { mode: "local", - remote: { token: "remote-token", password: "remote-password" }, - auth: { token: "local-token", password: "local-password" }, + remote: { token: "remote-token", password: "remote-password" }, // pragma: allowlist secret + auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret }, }), env: {} as NodeJS.ProcessEnv, @@ -232,7 +232,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); expect(resolved).toEqual({ token: "local-token", - password: "local-password", + password: "local-password", // pragma: allowlist secret }); }); @@ -240,7 +240,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { const resolved = resolveRemoteModeWithRemoteCredentials(); expect(resolved).toEqual({ token: "remote-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); }); @@ -255,22 +255,22 @@ describe("resolveGatewayCredentialsFromConfig", () => { it("supports env-first password override in remote mode for gateway call path", () => { const resolved = resolveRemoteModeWithRemoteCredentials({ - remotePasswordPrecedence: "env-first", + remotePasswordPrecedence: "env-first", // pragma: allowlist secret }); expect(resolved).toEqual({ token: "remote-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); }); it("supports env-first token precedence in remote mode", () => { const resolved = resolveRemoteModeWithRemoteCredentials({ remoteTokenPrecedence: "env-first", - remotePasswordPrecedence: "remote-first", + remotePasswordPrecedence: "remote-first", // pragma: allowlist secret }); expect(resolved).toEqual({ token: "env-token", - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }); }); @@ -282,7 +282,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { auth: DEFAULT_GATEWAY_AUTH, }, { - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }, ); expect(resolved).toEqual({ @@ -333,29 +333,33 @@ describe("resolveGatewayCredentialsFromConfig", () => { ).toThrow("gateway.remote.token"); }); + function createRemoteConfigWithMissingLocalTokenRef() { + return { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig; + } + it("ignores unresolved local token ref in remote-only mode when local auth mode is token", () => { const resolved = resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example", - }, - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, + cfg: createRemoteConfigWithMissingLocalTokenRef(), env: {} as NodeJS.ProcessEnv, includeLegacyEnv: false, remoteTokenFallback: "remote-only", - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }); expect(resolved).toEqual({ token: undefined, @@ -366,27 +370,11 @@ describe("resolveGatewayCredentialsFromConfig", () => { it("throws for unresolved local token ref in remote mode when local fallback is enabled", () => { expect(() => resolveGatewayCredentialsFromConfig({ - cfg: { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example", - }, - auth: { - mode: "token", - token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as unknown as OpenClawConfig, + cfg: createRemoteConfigWithMissingLocalTokenRef(), env: {} as NodeJS.ProcessEnv, includeLegacyEnv: false, remoteTokenFallback: "remote-env-local", - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }), ).toThrow("gateway.auth.token"); }); @@ -399,7 +387,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { remote: { url: "wss://gateway.example", token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }, auth: {}, }, @@ -414,7 +402,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); expect(resolved).toEqual({ token: undefined, - password: "remote-password", + password: "remote-password", // pragma: allowlist secret }); }); @@ -438,7 +426,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { } as unknown as OpenClawConfig, env: {} as NodeJS.ProcessEnv, includeLegacyEnv: false, - remotePasswordFallback: "remote-only", + remotePasswordFallback: "remote-only", // pragma: allowlist secret }), ).toThrow("gateway.remote.password"); }); @@ -452,7 +440,7 @@ describe("resolveGatewayCredentialsFromConfig", () => { }), env: { CLAWDBOT_GATEWAY_TOKEN: "legacy-token", - CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", + CLAWDBOT_GATEWAY_PASSWORD: "legacy-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, includeLegacyEnv: false, }); @@ -464,33 +452,33 @@ describe("resolveGatewayCredentialsFromValues", () => { it("supports config-first precedence for token/password", () => { const resolved = resolveGatewayCredentialsFromValues({ configToken: "config-token", - configPassword: "config-password", + configPassword: "config-password", // pragma: allowlist secret env: { OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, includeLegacyEnv: false, tokenPrecedence: "config-first", - passwordPrecedence: "config-first", + passwordPrecedence: "config-first", // pragma: allowlist secret }); expect(resolved).toEqual({ token: "config-token", - password: "config-password", + password: "config-password", // pragma: allowlist secret }); }); it("uses env-first precedence by default", () => { const resolved = resolveGatewayCredentialsFromValues({ configToken: "config-token", - configPassword: "config-password", + configPassword: "config-password", // pragma: allowlist secret env: { OPENCLAW_GATEWAY_TOKEN: "env-token", - OPENCLAW_GATEWAY_PASSWORD: "env-password", + OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, }); expect(resolved).toEqual({ token: "env-token", - password: "env-password", + password: "env-password", // pragma: allowlist secret }); }); }); diff --git a/src/gateway/input-allowlist.ts b/src/gateway/input-allowlist.ts new file mode 100644 index 00000000000..d59b3e6265c --- /dev/null +++ b/src/gateway/input-allowlist.ts @@ -0,0 +1,9 @@ +export function normalizeInputHostnameAllowlist( + values: string[] | undefined, +): string[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); + return normalized.length > 0 ? normalized : undefined; +} diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index f3ab97093ba..82130807a1b 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -137,6 +137,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } | undefined; const getFirstAgentMessage = () => getFirstAgentCall()?.message ?? ""; + const expectInvalidRequestNoDispatch = async (messages: unknown[]) => { + agentCommand.mockClear(); + const res = await postChatCompletions(port, { + model: "openclaw", + messages, + }); + expect(res.status).toBe(400); + const json = (await res.json()) as Record; + expect((json.error as Record | undefined)?.type).toBe( + "invalid_request_error", + ); + expect(agentCommand).toHaveBeenCalledTimes(0); + }; const postSyncUserMessage = async (message: string) => { const res = await postChatCompletions(port, { stream: false, @@ -308,27 +321,17 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } { - agentCommand.mockClear(); - const res = await postChatCompletions(port, { - model: "openclaw", - messages: [ - { - role: "user", - content: [ - { - type: "image_url", - image_url: { url: "https://example.com/image.png" }, - }, - ], - }, - ], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - expect(agentCommand).toHaveBeenCalledTimes(0); + await expectInvalidRequestNoDispatch([ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ]); } { @@ -423,50 +426,30 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } { - agentCommand.mockClear(); - const res = await postChatCompletions(port, { - model: "openclaw", - messages: [ - { - role: "user", - content: [ - { - type: "image_url", - image_url: { url: "data:application/pdf;base64,QUJDRA==" }, - }, - ], - }, - ], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - expect(agentCommand).toHaveBeenCalledTimes(0); + await expectInvalidRequestNoDispatch([ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "data:application/pdf;base64,QUJDRA==" }, + }, + ], + }, + ]); } { - agentCommand.mockClear(); const manyImageParts = Array.from({ length: 9 }).map(() => ({ type: "image_url", image_url: { url: "data:image/png;base64,QUJDRA==" }, })); - const res = await postChatCompletions(port, { - model: "openclaw", - messages: [ - { - role: "user", - content: manyImageParts, - }, - ], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - expect(agentCommand).toHaveBeenCalledTimes(0); + await expectInvalidRequestNoDispatch([ + { + role: "user", + content: manyImageParts, + }, + ]); } { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 01564f17b34..c4ffb02b148 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -28,6 +28,7 @@ import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { resolveGatewayRequestContext } from "./http-utils.js"; +import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; @@ -70,14 +71,6 @@ type ResolvedOpenAiChatCompletionsLimits = { images: InputImageLimits; }; -function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined { - if (!values || values.length === 0) { - return undefined; - } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); - return normalized.length > 0 ? normalized : undefined; -} - function resolveOpenAiChatCompletionsLimits( config: GatewayHttpChatCompletionsConfig | undefined, ): ResolvedOpenAiChatCompletionsLimits { @@ -94,7 +87,7 @@ function resolveOpenAiChatCompletionsLimits( : DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES, images: { allowUrl: imageConfig?.allowUrl ?? DEFAULT_OPENAI_IMAGE_LIMITS.allowUrl, - urlAllowlist: normalizeHostnameAllowlist(imageConfig?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(imageConfig?.urlAllowlist), allowedMimes: normalizeMimeList(imageConfig?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: imageConfig?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: imageConfig?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 783772016ed..97a5fee3c66 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -35,6 +35,7 @@ import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; import { resolveGatewayRequestContext } from "./http-utils.js"; +import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; import { CreateResponseBodySchema, type CreateResponseBody, @@ -69,14 +70,6 @@ type ResolvedResponsesLimits = { images: InputImageLimits; }; -function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined { - if (!values || values.length === 0) { - return undefined; - } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); - return normalized.length > 0 ? normalized : undefined; -} - function resolveResponsesLimits( config: GatewayHttpResponsesConfig | undefined, ): ResolvedResponsesLimits { @@ -91,11 +84,11 @@ function resolveResponsesLimits( : DEFAULT_MAX_URL_PARTS, files: { ...fileLimits, - urlAllowlist: normalizeHostnameAllowlist(files?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(files?.urlAllowlist), }, images: { allowUrl: images?.allowUrl ?? true, - urlAllowlist: normalizeHostnameAllowlist(images?.urlAllowlist), + urlAllowlist: normalizeInputHostnameAllowlist(images?.urlAllowlist), allowedMimes: normalizeMimeList(images?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: images?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: images?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 612ce90dbba..e5d2a5fa0e1 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -383,6 +383,14 @@ export function createHooksRequestHandler( return true; } + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + const token = extractHookToken(req); const clientKey = resolveHookClientKey(req); if (!safeEqualSecret(token, hooksConfig.token)) { @@ -404,14 +412,6 @@ export function createHooksRequestHandler( } hookAuthLimiter.reset(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH); - if (req.method !== "POST") { - res.statusCode = 405; - res.setHeader("Allow", "POST"); - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Method Not Allowed"); - return true; - } - const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); if (!subPath) { res.statusCode = 404; diff --git a/src/gateway/server-maintenance.test.ts b/src/gateway/server-maintenance.test.ts index 4976a34470e..045f73d802a 100644 --- a/src/gateway/server-maintenance.test.ts +++ b/src/gateway/server-maintenance.test.ts @@ -11,6 +11,41 @@ vi.mock("../media/store.js", async (importOriginal) => { }; }); +const MEDIA_CLEANUP_TTL_MS = 24 * 60 * 60_000; + +function createMaintenanceTimerDeps() { + return { + broadcast: () => {}, + nodeSendToAllSubscribed: () => {}, + getPresenceVersion: () => 1, + getHealthVersion: () => 1, + refreshGatewayHealthSnapshot: async () => ({ ok: true }) as HealthSummary, + logHealth: { error: () => {} }, + dedupe: new Map(), + chatAbortControllers: new Map(), + chatRunState: { abortedRuns: new Map() }, + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + removeChatRun: () => undefined, + agentRunSeq: new Map(), + nodeSendToSession: () => {}, + }; +} + +function stopMaintenanceTimers(timers: { + tickInterval: NodeJS.Timeout; + healthInterval: NodeJS.Timeout; + dedupeCleanup: NodeJS.Timeout; + mediaCleanup: NodeJS.Timeout | null; +}) { + clearInterval(timers.tickInterval); + clearInterval(timers.healthInterval); + clearInterval(timers.dedupeCleanup); + if (timers.mediaCleanup) { + clearInterval(timers.mediaCleanup); + } +} + describe("startGatewayMaintenanceTimers", () => { afterEach(() => { vi.useRealTimers(); @@ -22,28 +57,13 @@ describe("startGatewayMaintenanceTimers", () => { const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js"); const timers = startGatewayMaintenanceTimers({ - broadcast: () => {}, - nodeSendToAllSubscribed: () => {}, - getPresenceVersion: () => 1, - getHealthVersion: () => 1, - refreshGatewayHealthSnapshot: async () => ({ ok: true }) as HealthSummary, - logHealth: { error: () => {} }, - dedupe: new Map(), - chatAbortControllers: new Map(), - chatRunState: { abortedRuns: new Map() }, - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - removeChatRun: () => undefined, - agentRunSeq: new Map(), - nodeSendToSession: () => {}, + ...createMaintenanceTimerDeps(), }); expect(cleanOldMediaMock).not.toHaveBeenCalled(); expect(timers.mediaCleanup).toBeNull(); - clearInterval(timers.tickInterval); - clearInterval(timers.healthInterval); - clearInterval(timers.dedupeCleanup); + stopMaintenanceTimers(timers); }); it("runs startup media cleanup and repeats it hourly", async () => { @@ -51,41 +71,23 @@ describe("startGatewayMaintenanceTimers", () => { const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js"); const timers = startGatewayMaintenanceTimers({ - broadcast: () => {}, - nodeSendToAllSubscribed: () => {}, - getPresenceVersion: () => 1, - getHealthVersion: () => 1, - refreshGatewayHealthSnapshot: async () => ({ ok: true }) as HealthSummary, - logHealth: { error: () => {} }, - dedupe: new Map(), - chatAbortControllers: new Map(), - chatRunState: { abortedRuns: new Map() }, - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - removeChatRun: () => undefined, - agentRunSeq: new Map(), - nodeSendToSession: () => {}, - mediaCleanupTtlMs: 24 * 60 * 60_000, + ...createMaintenanceTimerDeps(), + mediaCleanupTtlMs: MEDIA_CLEANUP_TTL_MS, }); - expect(cleanOldMediaMock).toHaveBeenCalledWith(24 * 60 * 60_000, { + expect(cleanOldMediaMock).toHaveBeenCalledWith(MEDIA_CLEANUP_TTL_MS, { recursive: true, pruneEmptyDirs: true, }); cleanOldMediaMock.mockClear(); await vi.advanceTimersByTimeAsync(60 * 60_000); - expect(cleanOldMediaMock).toHaveBeenCalledWith(24 * 60 * 60_000, { + expect(cleanOldMediaMock).toHaveBeenCalledWith(MEDIA_CLEANUP_TTL_MS, { recursive: true, pruneEmptyDirs: true, }); - clearInterval(timers.tickInterval); - clearInterval(timers.healthInterval); - clearInterval(timers.dedupeCleanup); - if (timers.mediaCleanup) { - clearInterval(timers.mediaCleanup); - } + stopMaintenanceTimers(timers); }); it("skips overlapping media cleanup runs", async () => { @@ -102,21 +104,8 @@ describe("startGatewayMaintenanceTimers", () => { const { startGatewayMaintenanceTimers } = await import("./server-maintenance.js"); const timers = startGatewayMaintenanceTimers({ - broadcast: () => {}, - nodeSendToAllSubscribed: () => {}, - getPresenceVersion: () => 1, - getHealthVersion: () => 1, - refreshGatewayHealthSnapshot: async () => ({ ok: true }) as HealthSummary, - logHealth: { error: () => {} }, - dedupe: new Map(), - chatAbortControllers: new Map(), - chatRunState: { abortedRuns: new Map() }, - chatRunBuffers: new Map(), - chatDeltaSentAt: new Map(), - removeChatRun: () => undefined, - agentRunSeq: new Map(), - nodeSendToSession: () => {}, - mediaCleanupTtlMs: 24 * 60 * 60_000, + ...createMaintenanceTimerDeps(), + mediaCleanupTtlMs: MEDIA_CLEANUP_TTL_MS, }); expect(cleanOldMediaMock).toHaveBeenCalledTimes(1); @@ -132,11 +121,6 @@ describe("startGatewayMaintenanceTimers", () => { await vi.advanceTimersByTimeAsync(60 * 60_000); expect(cleanOldMediaMock).toHaveBeenCalledTimes(2); - clearInterval(timers.tickInterval); - clearInterval(timers.healthInterval); - clearInterval(timers.dedupeCleanup); - if (timers.mediaCleanup) { - clearInterval(timers.mediaCleanup); - } + stopMaintenanceTimers(timers); }); }); diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index e49fc68eefa..7c98cd9133b 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -78,7 +78,7 @@ describe("push.test handler", () => { value: { teamId: "TEAM123", keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret }, }); vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index ecad50ced13..3817cead335 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -236,10 +236,10 @@ export function registerControlUiAndPairingSuite(): void { test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; - testState.gatewayAuth = { mode: "password", password: "secret" }; + testState.gatewayAuth = { mode: "password", password: "secret" }; // pragma: allowlist secret await withGatewayServer(async ({ port }) => { const ws = await openWs(port, { origin: originForPort(port) }); - await connectControlUiWithoutDeviceAndExpectOk({ ws, password: "secret" }); + await connectControlUiWithoutDeviceAndExpectOk({ ws, password: "secret" }); // pragma: allowlist secret ws.close(); }); }); diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 7a5d84e62d8..76c51cd6d78 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -141,6 +141,36 @@ describe("gateway server chat", () => { expect(res.payload?.startedAt).toBe(startedAt); }; + const mockBlockedChatReply = () => { + let releaseBlockedReply: (() => void) | undefined; + const blockedReply = new Promise((resolve) => { + releaseBlockedReply = resolve; + }); + const replySpy = vi.mocked(getReplyFromConfig); + replySpy.mockImplementationOnce(async (_ctx, opts) => { + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + resolve(); + }; + void blockedReply.then(finish); + if (opts?.abortSignal?.aborted) { + finish(); + return; + } + opts?.abortSignal?.addEventListener("abort", finish, { once: true }); + }); + return undefined; + }); + return () => { + releaseBlockedReply?.(); + }; + }; + test("sanitizes inbound chat.send message text and rejects null bytes", async () => { const nullByteRes = await rpcReq(ws, "chat.send", { sessionKey: "main", @@ -585,30 +615,7 @@ describe("gateway server chat", () => { expect(seedWaitRes.ok).toBe(true); expect(seedWaitRes.payload?.status).toBe("ok"); - let releaseBlockedReply: (() => void) | undefined; - const blockedReply = new Promise((resolve) => { - releaseBlockedReply = resolve; - }); - const replySpy = vi.mocked(getReplyFromConfig); - replySpy.mockImplementationOnce(async (_ctx, opts) => { - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) { - return; - } - settled = true; - resolve(); - }; - void blockedReply.then(finish); - if (opts?.abortSignal?.aborted) { - finish(); - return; - } - opts?.abortSignal?.addEventListener("abort", finish, { once: true }); - }); - return undefined; - }); + const releaseBlockedReply = mockBlockedChatReply(); try { const chatRes = await rpcReq(ws, "chat.send", { @@ -631,7 +638,7 @@ describe("gateway server chat", () => { }); expect(abortRes.ok).toBe(true); } finally { - releaseBlockedReply?.(); + releaseBlockedReply(); } }); }); @@ -639,30 +646,7 @@ describe("gateway server chat", () => { test("agent.wait keeps lifecycle wait active while same-runId chat.send is active", async () => { await withMainSessionStore(async () => { const runId = "idem-wait-chat-active-with-agent-lifecycle"; - let releaseBlockedReply: (() => void) | undefined; - const blockedReply = new Promise((resolve) => { - releaseBlockedReply = resolve; - }); - const replySpy = vi.mocked(getReplyFromConfig); - replySpy.mockImplementationOnce(async (_ctx, opts) => { - await new Promise((resolve) => { - let settled = false; - const finish = () => { - if (settled) { - return; - } - settled = true; - resolve(); - }; - void blockedReply.then(finish); - if (opts?.abortSignal?.aborted) { - finish(); - return; - } - opts?.abortSignal?.addEventListener("abort", finish, { once: true }); - }); - return undefined; - }); + const releaseBlockedReply = mockBlockedChatReply(); try { const chatRes = await rpcReq(ws, "chat.send", { @@ -700,7 +684,7 @@ describe("gateway server chat", () => { }); expect(abortRes.ok).toBe(true); } finally { - releaseBlockedReply?.(); + releaseBlockedReply(); } }); }); diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 0c125600f5d..6711671e4ee 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -383,4 +383,24 @@ describe("gateway server hooks", () => { expect(failAfterSuccess.status).toBe(401); }); }); + + test("rejects non-POST hook requests without consuming auth failure budget", async () => { + testState.hooksConfig = { enabled: true, token: HOOK_TOKEN }; + await withGatewayServer(async ({ port }) => { + let lastGet: Response | null = null; + for (let i = 0; i < 21; i++) { + lastGet = await fetch(`http://127.0.0.1:${port}/hooks/wake`, { + method: "GET", + headers: { Authorization: "Bearer wrong" }, + }); + } + expect(lastGet?.status).toBe(405); + expect(lastGet?.headers.get("allow")).toBe("POST"); + + const allowed = await postHook(port, "/hooks/wake", { text: "still works" }); + expect(allowed.status).toBe(200); + await waitForSystemEvent(); + drainSystemEvents(resolveMainKey()); + }); + }); }); diff --git a/src/gateway/server.legacy-migration.test.ts b/src/gateway/server.legacy-migration.test.ts index 0522f8a858e..71321390888 100644 --- a/src/gateway/server.legacy-migration.test.ts +++ b/src/gateway/server.legacy-migration.test.ts @@ -8,76 +8,51 @@ import { installGatewayTestHooks({ scope: "suite" }); +async function expectHeartbeatValidationError(legacyParsed: Record) { + testState.legacyIssues = [ + { + path: "heartbeat", + message: + "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", + }, + ]; + testState.legacyParsed = legacyParsed; + testState.migrationConfig = null; + testState.migrationChanges = []; + + let server: Awaited> | undefined; + let thrown: unknown; + try { + server = await startGatewayServer(await getFreePort()); + } catch (err) { + thrown = err; + } + + if (server) { + await server.close(); + } + + expect(thrown).toBeInstanceOf(Error); + const message = String((thrown as Error).message); + expect(message).toContain("Invalid config at"); + expect(message).toContain( + "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", + ); + expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); +} + describe("gateway startup legacy migration fallback", () => { test("surfaces detailed validation errors when legacy entries have no migration output", async () => { - testState.legacyIssues = [ - { - path: "heartbeat", - message: - "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - }, - ]; - testState.legacyParsed = { + await expectHeartbeatValidationError({ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" }, - }; - testState.migrationConfig = null; - testState.migrationChanges = []; - - let server: Awaited> | undefined; - let thrown: unknown; - try { - server = await startGatewayServer(await getFreePort()); - } catch (err) { - thrown = err; - } - - if (server) { - await server.close(); - } - - expect(thrown).toBeInstanceOf(Error); - const message = String((thrown as Error).message); - expect(message).toContain("Invalid config at"); - expect(message).toContain( - "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - ); - expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); + }); }); test("keeps detailed validation errors when heartbeat comes from include-resolved config", async () => { - testState.legacyIssues = [ - { - path: "heartbeat", - message: - "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - }, - ]; // Simulate a parsed source that only contains include directives, while // legacy heartbeat is surfaced from the resolved config. - testState.legacyParsed = { + await expectHeartbeatValidationError({ $include: ["heartbeat.defaults.json"], - }; - testState.migrationConfig = null; - testState.migrationChanges = []; - - let server: Awaited> | undefined; - let thrown: unknown; - try { - server = await startGatewayServer(await getFreePort()); - } catch (err) { - thrown = err; - } - - if (server) { - await server.close(); - } - - expect(thrown).toBeInstanceOf(Error); - const message = String((thrown as Error).message); - expect(message).toContain("Invalid config at"); - expect(message).toContain( - "heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", - ); - expect(message).not.toContain("Legacy config entries detected but auto-migration failed."); + }); }); }); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index f58acb0dfe5..6eb9399e23a 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -56,6 +56,23 @@ const withRootMountedControlUiServer = (params: { const withPluginGatewayServer = (params: Parameters[0]) => withGatewayServer(params); +const PROBE_CASES = [ + { path: "/health", status: "live" }, + { path: "/healthz", status: "live" }, + { path: "/ready", status: "ready" }, + { path: "/readyz", status: "ready" }, +] as const; + +async function expectProbeRoutesHealthy(server: Parameters[0]) { + for (const probeCase of PROBE_CASES) { + const response = await sendRequest(server, { path: probeCase.path }); + expect(response.res.statusCode, probeCase.path).toBe(200); + expect(response.getBody(), probeCase.path).toBe( + JSON.stringify({ ok: true, status: probeCase.status }), + ); + } +} + function createProtectedPluginAuthOverrides(handlePluginRequest: PluginRequestHandler) { return { handlePluginRequest, @@ -98,20 +115,7 @@ describe("gateway plugin HTTP auth boundary", () => { prefix: "openclaw-plugin-http-probes-test-", resolvedAuth: AUTH_TOKEN, run: async (server) => { - const probeCases = [ - { path: "/health", status: "live" }, - { path: "/healthz", status: "live" }, - { path: "/ready", status: "ready" }, - { path: "/readyz", status: "ready" }, - ] as const; - - for (const probeCase of probeCases) { - const response = await sendRequest(server, { path: probeCase.path }); - expect(response.res.statusCode, probeCase.path).toBe(200); - expect(response.getBody(), probeCase.path).toBe( - JSON.stringify({ ok: true, status: probeCase.status }), - ); - } + await expectProbeRoutesHealthy(server); }, }); }); @@ -501,22 +505,8 @@ describe("gateway plugin HTTP auth boundary", () => { prefix: "openclaw-plugin-http-control-ui-probes-test-", handlePluginRequest, run: async (server) => { - const probeCases = [ - { path: "/health", status: "live" }, - { path: "/healthz", status: "live" }, - { path: "/ready", status: "ready" }, - { path: "/readyz", status: "ready" }, - ] as const; - - for (const probeCase of probeCases) { - const response = await sendRequest(server, { path: probeCase.path }); - expect(response.res.statusCode, probeCase.path).toBe(200); - expect(response.getBody(), probeCase.path).toBe( - JSON.stringify({ ok: true, status: probeCase.status }), - ); - } - - expect(handlePluginRequest).toHaveBeenCalledTimes(probeCases.length); + await expectProbeRoutesHealthy(server); + expect(handlePluginRequest).toHaveBeenCalledTimes(PROBE_CASES.length); }, }); }); diff --git a/src/gateway/server.skills-status.test.ts b/src/gateway/server.skills-status.test.ts index 746574dc977..3aa3c82a816 100644 --- a/src/gateway/server.skills-status.test.ts +++ b/src/gateway/server.skills-status.test.ts @@ -11,7 +11,7 @@ describe("gateway skills.status", () => { await withEnvAsync( { OPENCLAW_BUNDLED_SKILLS_DIR: path.join(process.cwd(), "skills") }, async () => { - const secret = "discord-token-secret-abc"; + const secret = "discord-token-secret-abc"; // pragma: allowlist secret const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ session: { mainKey: "main-test" }, diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 107d8a83263..42e200d8968 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -67,7 +67,7 @@ describe("gateway talk.config", () => { await writeConfigFile({ talk: { voiceId: "voice-123", - apiKey: "secret-key-abc", + apiKey: "secret-key-abc", // pragma: allowlist secret }, session: { mainKey: "main-test", @@ -103,7 +103,7 @@ describe("gateway talk.config", () => { }); it("requires operator.talk.secrets for includeSecrets", async () => { - await writeTalkConfig({ apiKey: "secret-key-abc" }); + await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret await withServer(async (ws) => { await connectOperator(ws, ["operator.read"]); @@ -114,7 +114,7 @@ describe("gateway talk.config", () => { }); it("returns secrets for operator.talk.secrets scope", async () => { - await writeTalkConfig({ apiKey: "secret-key-abc" }); + await writeTalkConfig({ apiKey: "secret-key-abc" }); // pragma: allowlist secret await withServer(async (ws) => { await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]); diff --git a/src/gateway/server/readiness.test.ts b/src/gateway/server/readiness.test.ts index 9e502077d20..2ad29d3655a 100644 --- a/src/gateway/server/readiness.test.ts +++ b/src/gateway/server/readiness.test.ts @@ -31,23 +31,27 @@ function createManager(snapshot: ChannelRuntimeSnapshot): ChannelManager { }; } +function createHealthyDiscordManager(startedAt: number, lastEventAt: number): ChannelManager { + return createManager( + snapshotWith({ + discord: { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: startedAt, + lastEventAt, + }, + }), + ); +} + describe("createReadinessChecker", () => { it("reports ready when all managed channels are healthy", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); const startedAt = Date.now() - 5 * 60_000; - const manager = createManager( - snapshotWith({ - discord: { - running: true, - connected: true, - enabled: true, - configured: true, - lastStartAt: startedAt, - lastEventAt: Date.now() - 1_000, - }, - }), - ); + const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); const readiness = createReadinessChecker({ channelManager: manager, startedAt }); expect(readiness()).toEqual({ ready: true, failing: [], uptimeMs: 300_000 }); @@ -193,18 +197,7 @@ describe("createReadinessChecker", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-06T12:00:00Z")); const startedAt = Date.now() - 5 * 60_000; - const manager = createManager( - snapshotWith({ - discord: { - running: true, - connected: true, - enabled: true, - configured: true, - lastStartAt: startedAt, - lastEventAt: Date.now() - 1_000, - }, - }), - ); + const manager = createHealthyDiscordManager(startedAt, Date.now() - 1_000); const readiness = createReadinessChecker({ channelManager: manager, diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index b5c4e19bdee..c2ad8a51915 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -122,7 +122,7 @@ describe("ensureGatewayStartupAuth", () => { }, }, env: { - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret } as NodeJS.ProcessEnv, persist: true, }); @@ -252,7 +252,7 @@ describe("ensureGatewayStartupAuth", () => { gateway: { auth: { token: "configured-token", - password: "configured-password", + password: "configured-password", // pragma: allowlist secret }, }, }, @@ -279,7 +279,7 @@ describe("ensureGatewayStartupAuth", () => { }, }, env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret } as NodeJS.ProcessEnv, persist: true, }); @@ -390,7 +390,7 @@ describe("ensureGatewayStartupAuth", () => { await expectEphemeralGeneratedTokenWhenOverridden({ gateway: { auth: { - password: "configured-password", + password: "configured-password", // pragma: allowlist secret }, }, }); @@ -445,7 +445,7 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => { auth: { mode: "password", modeSource: "config", - password: "pw", + password: "pw", // pragma: allowlist secret allowTailscale: false, }, }), diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 4e6410c4b36..1817cc7e7d6 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -15,6 +15,20 @@ function okResponse(body = "ok"): Response { describe("fetchWithSsrFGuard hardening", () => { type LookupFn = NonNullable[0]["lookupFn"]>; + const CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS = [ + "authorization", + "proxy-authorization", + "cookie", + "cookie2", + "x-api-key", + "private-token", + "x-trace", + ] as const; + const CROSS_ORIGIN_REDIRECT_PRESERVED_HEADERS = [ + ["accept", "application/json"], + ["content-type", "application/json"], + ["user-agent", "OpenClaw-Test/1.0"], + ] as const; const createPublicLookup = (): LookupFn => vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn; @@ -154,17 +168,23 @@ 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", }, }, }); const headers = getSecondRequestHeaders(fetchImpl); - expect(headers.get("authorization")).toBeNull(); - expect(headers.get("proxy-authorization")).toBeNull(); - expect(headers.get("cookie")).toBeNull(); - expect(headers.get("cookie2")).toBeNull(); - expect(headers.get("x-trace")).toBe("1"); + for (const header of CROSS_ORIGIN_REDIRECT_STRIPPED_HEADERS) { + expect(headers.get(header)).toBeNull(); + } + for (const [header, value] of CROSS_ORIGIN_REDIRECT_PRESERVED_HEADERS) { + expect(headers.get(header)).toBe(value); + } await result.release(); }); diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index ded0c5fae21..faae38b013c 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -52,12 +52,21 @@ type GuardedFetchPresetOptions = Omit< >; const DEFAULT_MAX_REDIRECTS = 3; -const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [ - "authorization", - "proxy-authorization", - "cookie", - "cookie2", -]; +const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([ + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "content-language", + "content-type", + "if-match", + "if-modified-since", + "if-none-match", + "if-unmodified-since", + "pragma", + "range", + "user-agent", +]); export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions { return { ...params, mode: GUARDED_FETCH_MODE.STRICT }; @@ -83,13 +92,16 @@ function isRedirectStatus(status: number): boolean { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } -function stripSensitiveHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { +function retainSafeHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined { 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 }; } @@ -214,7 +226,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { @@ -403,4 +404,76 @@ describe("resolveProviderAuths key normalization", () => { expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]); }, {}); }); + + it("ignores marker-backed config keys for provider usage auth resolution", async () => { + await withSuiteHome( + async (home) => { + const modelDef = { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + await writeConfig(home, { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [modelDef], + apiKey: NON_ENV_SECRETREF_MARKER, + }, + }, + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["minimax"], + }); + expect(auths).toEqual([]); + }, + { + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + }, + ); + }); + + it("keeps all-caps plaintext config keys eligible for provider usage auth resolution", async () => { + await withSuiteHome( + async (home) => { + const modelDef = { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + await writeConfig(home, { + models: { + providers: { + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [modelDef], + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + }, + }, + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["minimax"], + }); + expect(auths).toEqual([{ provider: "minimax", token: "ALLCAPS_SAMPLE" }]); + }, + { + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + }, + ); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index ff63c1570f1..6afa4bebaad 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -8,6 +8,7 @@ import { resolveApiKeyForProfile, resolveAuthProfileOrder, } from "../agents/auth-profiles.js"; +import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { getCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; @@ -103,7 +104,7 @@ function resolveProviderApiKeyFromConfigAndStore(params: { const cfg = loadConfig(); const key = getCustomProviderApiKey(cfg, params.providerId); - if (key) { + if (key && !isNonSecretApiKeyMarker(key)) { return key; } @@ -122,9 +123,17 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return undefined; } if (cred.type === "api_key") { - return normalizeSecretInput(cred.key); + const key = normalizeSecretInput(cred.key); + if (key && !isNonSecretApiKeyMarker(key)) { + return key; + } + return undefined; } - return normalizeSecretInput(cred.token); + const token = normalizeSecretInput(cred.token); + if (token && !isNonSecretApiKeyMarker(token)) { + return token; + } + return undefined; } async function resolveOAuthToken(params: { diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 1e72a3f2439..03c75110861 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -77,7 +77,7 @@ describe("push APNs env config", () => { OPENCLAW_APNS_TEAM_ID: "TEAM123", OPENCLAW_APNS_KEY_ID: "KEY123", OPENCLAW_APNS_PRIVATE_KEY_P8: - "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", // pragma: allowlist secret } as NodeJS.ProcessEnv; const resolved = await resolveApnsAuthConfigFromEnv(env); expect(resolved.ok).toBe(true); diff --git a/src/line/bot-handlers.test.ts b/src/line/bot-handlers.test.ts index 39bfdf939e0..c7752c506e7 100644 --- a/src/line/bot-handlers.test.ts +++ b/src/line/bot-handlers.test.ts @@ -65,9 +65,50 @@ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({ let handleLineWebhookEvents: typeof import("./bot-handlers.js").handleLineWebhookEvents; let createLineWebhookReplayCache: typeof import("./bot-handlers.js").createLineWebhookReplayCache; +type LineWebhookContext = Parameters[1]; const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }); +function createReplayMessageEvent(params: { + messageId: string; + groupId: string; + userId: string; + webhookEventId: string; + isRedelivery: boolean; +}) { + return { + type: "message", + message: { id: params.messageId, type: "text", text: "hello" }, + replyToken: "reply-token", + timestamp: Date.now(), + source: { type: "group", groupId: params.groupId, userId: params.userId }, + mode: "active", + webhookEventId: params.webhookEventId, + deliveryContext: { isRedelivery: params.isRedelivery }, + } as MessageEvent; +} + +function createOpenGroupReplayContext( + processMessage: LineWebhookContext["processMessage"], + replayCache: ReturnType, +): Parameters[1] { + return { + cfg: { channels: { line: { groupPolicy: "open" } } }, + account: { + accountId: "default", + enabled: true, + channelAccessToken: "token", + channelSecret: "secret", + tokenSource: "config", + config: { groupPolicy: "open" }, + }, + runtime: createRuntime(), + mediaMaxBytes: 1, + processMessage, + replayCache, + }; +} + vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: readAllowFromStoreMock, upsertChannelPairingRequest: upsertPairingRequestMock, @@ -354,8 +395,8 @@ describe("handleLineWebhookEvents", () => { account: { accountId: "work", enabled: true, - channelAccessToken: "token-work", - channelSecret: "secret-work", + channelAccessToken: "token-work", // pragma: allowlist secret + channelSecret: "secret-work", // pragma: allowlist secret tokenSource: "config", config: { dmPolicy: "pairing" }, }, @@ -377,32 +418,14 @@ describe("handleLineWebhookEvents", () => { it("deduplicates replayed webhook events by webhookEventId before processing", async () => { const processMessage = vi.fn(); - const event = { - type: "message", - message: { id: "m-replay", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-replay", userId: "user-replay" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-replay", + groupId: "group-replay", + userId: "user-replay", webhookEventId: "evt-replay-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); await handleLineWebhookEvents([event], context); await handleLineWebhookEvents([event], context); @@ -419,32 +442,14 @@ describe("handleLineWebhookEvents", () => { const processMessage = vi.fn(async () => { await firstDone; }); - const event = { - type: "message", - message: { id: "m-inflight", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-inflight", userId: "user-inflight" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-inflight", + groupId: "group-inflight", + userId: "user-inflight", webhookEventId: "evt-inflight-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); const firstRun = handleLineWebhookEvents([event], context); await Promise.resolve(); @@ -464,32 +469,14 @@ describe("handleLineWebhookEvents", () => { const processMessage = vi.fn(async () => { await firstDone; }); - const event = { - type: "message", - message: { id: "m-inflight-fail", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-inflight", userId: "user-inflight" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-inflight-fail", + groupId: "group-inflight", + userId: "user-inflight", webhookEventId: "evt-inflight-fail-1", - deliveryContext: { isRedelivery: true }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: true, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); const firstRun = handleLineWebhookEvents([event], context); await Promise.resolve(); @@ -604,32 +591,14 @@ describe("handleLineWebhookEvents", () => { .fn() .mockRejectedValueOnce(new Error("transient failure")) .mockResolvedValueOnce(undefined); - const event = { - type: "message", - message: { id: "m-fail-then-retry", type: "text", text: "hello" }, - replyToken: "reply-token", - timestamp: Date.now(), - source: { type: "group", groupId: "group-retry", userId: "user-retry" }, - mode: "active", + const event = createReplayMessageEvent({ + messageId: "m-fail-then-retry", + groupId: "group-retry", + userId: "user-retry", webhookEventId: "evt-fail-then-retry", - deliveryContext: { isRedelivery: false }, - } as MessageEvent; - - const context: Parameters[1] = { - cfg: { channels: { line: { groupPolicy: "open" } } }, - account: { - accountId: "default", - enabled: true, - channelAccessToken: "token", - channelSecret: "secret", - tokenSource: "config", - config: { groupPolicy: "open" }, - }, - runtime: createRuntime(), - mediaMaxBytes: 1, - processMessage, - replayCache: createLineWebhookReplayCache(), - }; + isRedelivery: false, + }); + const context = createOpenGroupReplayContext(processMessage, createLineWebhookReplayCache()); await expect(handleLineWebhookEvents([event], context)).rejects.toThrow("transient failure"); await handleLineWebhookEvents([event], context); diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index f28d41e66cf..06ed5b0d09b 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -28,6 +28,7 @@ import { isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore, + type NormalizedAllowFrom, } from "./bot-access.js"; import { getLineSourceInfo, @@ -350,17 +351,15 @@ async function shouldProcessLineEvent( return denied; } } - const allowForCommands = effectiveGroupAllow; - const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - }); - return { allowed: true, commandAuthorized: commandGate.commandAuthorized }; + return { + allowed: true, + commandAuthorized: resolveLineCommandAuthorized({ + cfg, + event, + senderId, + allow: effectiveGroupAllow, + }), + }; } if (dmPolicy === "disabled") { @@ -386,17 +385,15 @@ async function shouldProcessLineEvent( return denied; } - const allowForCommands = effectiveDmAllow; - const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const rawText = resolveEventRawText(event); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], - allowTextCommands: true, - hasControlCommand: hasControlCommand(rawText, cfg), - }); - return { allowed: true, commandAuthorized: commandGate.commandAuthorized }; + return { + allowed: true, + commandAuthorized: resolveLineCommandAuthorized({ + cfg, + event, + senderId, + allow: effectiveDmAllow, + }), + }; } function resolveEventRawText(event: MessageEvent | PostbackEvent): string { @@ -413,6 +410,27 @@ function resolveEventRawText(event: MessageEvent | PostbackEvent): string { return ""; } +function resolveLineCommandAuthorized(params: { + cfg: OpenClawConfig; + event: MessageEvent | PostbackEvent; + senderId?: string; + allow: NormalizedAllowFrom; +}): boolean { + const senderAllowedForCommands = isSenderAllowed({ + allow: params.allow, + senderId: params.senderId, + }); + const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const rawText = resolveEventRawText(params.event); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }], + allowTextCommands: true, + hasControlCommand: hasControlCommand(rawText, params.cfg), + }); + return commandGate.commandAuthorized; +} + async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise { const { cfg, account, runtime, mediaMaxBytes, processMessage } = context; const message = event.message; diff --git a/src/line/bot-message-context.test.ts b/src/line/bot-message-context.test.ts index f6d6583a60b..52cd87b72ab 100644 --- a/src/line/bot-message-context.test.ts +++ b/src/line/bot-message-context.test.ts @@ -176,7 +176,7 @@ describe("buildLineMessageContext", () => { }); it("group peer binding matches raw groupId without prefix (#21907)", async () => { - const groupId = "Cc7e3bece1234567890abcdef"; + const groupId = "Cc7e3bece1234567890abcdef"; // pragma: allowlist secret const bindingCfg: OpenClawConfig = { session: { store: storePath }, agents: { diff --git a/src/line/monitor.lifecycle.test.ts b/src/line/monitor.lifecycle.test.ts index eafd330b79e..d1ad3194096 100644 --- a/src/line/monitor.lifecycle.test.ts +++ b/src/line/monitor.lifecycle.test.ts @@ -88,7 +88,7 @@ describe("monitorLineProvider lifecycle", () => { const task = monitorLineProvider({ channelAccessToken: "token", - channelSecret: "secret", + channelSecret: "secret", // pragma: allowlist secret config: {} as OpenClawConfig, runtime: {} as RuntimeEnv, abortSignal: abort.signal, @@ -115,7 +115,7 @@ describe("monitorLineProvider lifecycle", () => { await monitorLineProvider({ channelAccessToken: "token", - channelSecret: "secret", + channelSecret: "secret", // pragma: allowlist secret config: {} as OpenClawConfig, runtime: {} as RuntimeEnv, abortSignal: abort.signal, @@ -129,7 +129,7 @@ describe("monitorLineProvider lifecycle", () => { const monitor = await monitorLineProvider({ channelAccessToken: "token", - channelSecret: "secret", + channelSecret: "secret", // pragma: allowlist secret config: {} as OpenClawConfig, runtime: {} as RuntimeEnv, }); diff --git a/src/markdown/fences.ts b/src/markdown/fences.ts index d3cbbced1c6..282b6ecc296 100644 --- a/src/markdown/fences.ts +++ b/src/markdown/fences.ts @@ -73,7 +73,27 @@ export function parseFenceSpans(buffer: string): FenceSpan[] { } export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined { - return spans.find((span) => index > span.start && index < span.end); + let low = 0; + let high = spans.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const span = spans[mid]; + if (!span) { + break; + } + if (index <= span.start) { + high = mid - 1; + continue; + } + if (index >= span.end) { + low = mid + 1; + continue; + } + return span; + } + + return undefined; } export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean { diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 5e027f90541..ae62d294989 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -12,7 +12,7 @@ import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider: vi.fn(async () => ({ - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index f49bd859e31..10e5da610cc 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -14,7 +14,7 @@ import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider: vi.fn(async () => ({ - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", })), @@ -243,7 +243,7 @@ describe("applyMediaUnderstanding", () => { beforeEach(() => { mockedResolveApiKey.mockReset(); mockedResolveApiKey.mockResolvedValue({ - apiKey: "test-key", + apiKey: "test-key", // pragma: allowlist secret source: "test", mode: "api-key", }); diff --git a/src/media-understanding/providers/mistral/index.test.ts b/src/media-understanding/providers/mistral/index.test.ts index 44af01ff0ad..b368e516667 100644 --- a/src/media-understanding/providers/mistral/index.test.ts +++ b/src/media-understanding/providers/mistral/index.test.ts @@ -20,7 +20,7 @@ describe("mistralProvider", () => { const result = await mistralProvider.transcribeAudio!({ buffer: Buffer.from("audio-bytes"), fileName: "voice.ogg", - apiKey: "test-mistral-key", + apiKey: "test-mistral-key", // pragma: allowlist secret timeoutMs: 5000, fetchFn, }); @@ -35,7 +35,7 @@ describe("mistralProvider", () => { await mistralProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "note.mp3", - apiKey: "key", + apiKey: "key", // pragma: allowlist secret timeoutMs: 1000, baseUrl: "https://custom.mistral.example/v1", fetchFn, diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 38df19b7432..253c8d6eefa 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -29,7 +29,10 @@ describe("runCapability deepgram provider options", () => { deepgram: { baseUrl: "https://provider.example", apiKey: "test-key", - headers: { "X-Provider": "1" }, + headers: { + "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", + }, models: [], }, }, @@ -39,7 +42,10 @@ describe("runCapability deepgram provider options", () => { audio: { enabled: true, baseUrl: "https://config.example", - headers: { "X-Config": "2" }, + headers: { + "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", + }, providerOptions: { deepgram: { detect_language: true, @@ -52,7 +58,10 @@ describe("runCapability deepgram provider options", () => { provider: "deepgram", model: "nova-3", baseUrl: "https://entry.example", - headers: { "X-Entry": "3" }, + headers: { + "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", + }, providerOptions: { deepgram: { detectLanguage: false, @@ -79,8 +88,11 @@ describe("runCapability deepgram provider options", () => { expect(seenBaseUrl).toBe("https://entry.example"); expect(seenHeaders).toMatchObject({ "X-Provider": "1", + "X-Provider-Managed": "secretref-managed", "X-Config": "2", + "X-Config-Managed": "secretref-env:DEEPGRAM_HEADER_TOKEN", "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", }); expect(seenQuery).toMatchObject({ detect_language: false, diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 8423ece464d..cdd9468c4a7 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -40,6 +40,26 @@ import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; export type ProviderRegistry = Map; +function sanitizeProviderHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value !== "string") { + continue; + } + // Intentionally preserve marker-shaped values here. This path handles + // explicit config/runtime provider headers, where literal values may + // legitimately match marker patterns; discovered models.json entries are + // sanitized separately in the model registry path. + next[key] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +} + function trimOutput(text: string, maxChars?: number): string { const trimmed = text.trim(); if (!maxChars || trimmed.length <= maxChars) { @@ -352,9 +372,9 @@ async function resolveProviderExecutionContext(params: { }); const baseUrl = params.entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl; const mergedHeaders = { - ...providerConfig?.headers, - ...params.config?.headers, - ...params.entry.headers, + ...sanitizeProviderHeaders(providerConfig?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.config?.headers as Record | undefined), + ...sanitizeProviderHeaders(params.entry.headers as Record | undefined), }; const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined; return { apiKeys, baseUrl, headers }; diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 6991cf1a4ac..90eab226cea 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -14,7 +14,7 @@ describe("runCapability video provider wiring", () => { models: { providers: { moonshot: { - apiKey: "provider-key", + apiKey: "provider-key", // pragma: allowlist secret baseUrl: "https://provider.example/v1", headers: { "X-Provider": "1" }, models: [], @@ -85,7 +85,7 @@ describe("runCapability video provider wiring", () => { models: { providers: { moonshot: { - apiKey: "moonshot-key", + apiKey: "moonshot-key", // pragma: allowlist secret models: [], }, }, diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts index e29939dbacb..910a7515696 100644 --- a/src/memory/embeddings-ollama.test.ts +++ b/src/memory/embeddings-ollama.test.ts @@ -44,7 +44,7 @@ describe("embeddings-ollama", () => { providers: { ollama: { baseUrl: "http://127.0.0.1:11434/v1", - apiKey: "ollama-\nlocal\r\n", + apiKey: "ollama-\nlocal\r\n", // pragma: allowlist secret headers: { "X-Provider-Header": "provider", }, diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 027673c7099..df22885fefd 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -516,20 +516,32 @@ describe("local embedding ensureContext concurrency", () => { vi.doUnmock("./node-llama.js"); }); - it("loads the model only once when embedBatch is called concurrently", async () => { + async function setupLocalProviderWithMockedInit(params?: { + initializationDelayMs?: number; + failFirstGetLlama?: boolean; + }) { const getLlamaSpy = vi.fn(); const loadModelSpy = vi.fn(); const createContextSpy = vi.fn(); + let shouldFail = params?.failFirstGetLlama ?? false; const nodeLlamaModule = await import("./node-llama.js"); vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp").mockResolvedValue({ getLlama: async (...args: unknown[]) => { getLlamaSpy(...args); - await new Promise((r) => setTimeout(r, 50)); + if (shouldFail) { + shouldFail = false; + throw new Error("transient init failure"); + } + if (params?.initializationDelayMs) { + await new Promise((r) => setTimeout(r, params.initializationDelayMs)); + } return { loadModel: async (...modelArgs: unknown[]) => { loadModelSpy(...modelArgs); - await new Promise((r) => setTimeout(r, 50)); + if (params?.initializationDelayMs) { + await new Promise((r) => setTimeout(r, params.initializationDelayMs)); + } return { createEmbeddingContext: async () => { createContextSpy(); @@ -548,7 +560,6 @@ describe("local embedding ensureContext concurrency", () => { } as never); const { createEmbeddingProvider } = await import("./embeddings.js"); - const result = await createEmbeddingProvider({ config: {} as never, provider: "local", @@ -556,7 +567,20 @@ describe("local embedding ensureContext concurrency", () => { fallback: "none", }); - const provider = requireProvider(result); + return { + provider: requireProvider(result), + getLlamaSpy, + loadModelSpy, + createContextSpy, + }; + } + + it("loads the model only once when embedBatch is called concurrently", async () => { + const { provider, getLlamaSpy, loadModelSpy, createContextSpy } = + await setupLocalProviderWithMockedInit({ + initializationDelayMs: 50, + }); + const results = await Promise.all([ provider.embedBatch(["text1"]), provider.embedBatch(["text2"]), @@ -576,49 +600,11 @@ describe("local embedding ensureContext concurrency", () => { }); it("retries initialization after a transient ensureContext failure", async () => { - const getLlamaSpy = vi.fn(); - const loadModelSpy = vi.fn(); - const createContextSpy = vi.fn(); + const { provider, getLlamaSpy, loadModelSpy, createContextSpy } = + await setupLocalProviderWithMockedInit({ + failFirstGetLlama: true, + }); - let failFirstGetLlama = true; - const nodeLlamaModule = await import("./node-llama.js"); - vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp").mockResolvedValue({ - getLlama: async (...args: unknown[]) => { - getLlamaSpy(...args); - if (failFirstGetLlama) { - failFirstGetLlama = false; - throw new Error("transient init failure"); - } - return { - loadModel: async (...modelArgs: unknown[]) => { - loadModelSpy(...modelArgs); - return { - createEmbeddingContext: async () => { - createContextSpy(); - return { - getEmbeddingFor: vi.fn().mockResolvedValue({ - vector: new Float32Array([1, 0, 0, 0]), - }), - }; - }, - }; - }, - }; - }, - resolveModelFile: async () => "/fake/model.gguf", - LlamaLogLevel: { error: 0 }, - } as never); - - const { createEmbeddingProvider } = await import("./embeddings.js"); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "local", - model: "", - fallback: "none", - }); - - const provider = requireProvider(result); await expect(provider.embedBatch(["first"])).rejects.toThrow("transient init failure"); const recovered = await provider.embedBatch(["second"]); @@ -631,46 +617,11 @@ describe("local embedding ensureContext concurrency", () => { }); it("shares initialization when embedQuery and embedBatch start concurrently", async () => { - const getLlamaSpy = vi.fn(); - const loadModelSpy = vi.fn(); - const createContextSpy = vi.fn(); + const { provider, getLlamaSpy, loadModelSpy, createContextSpy } = + await setupLocalProviderWithMockedInit({ + initializationDelayMs: 50, + }); - const nodeLlamaModule = await import("./node-llama.js"); - vi.spyOn(nodeLlamaModule, "importNodeLlamaCpp").mockResolvedValue({ - getLlama: async (...args: unknown[]) => { - getLlamaSpy(...args); - await new Promise((r) => setTimeout(r, 50)); - return { - loadModel: async (...modelArgs: unknown[]) => { - loadModelSpy(...modelArgs); - await new Promise((r) => setTimeout(r, 50)); - return { - createEmbeddingContext: async () => { - createContextSpy(); - return { - getEmbeddingFor: vi.fn().mockResolvedValue({ - vector: new Float32Array([1, 0, 0, 0]), - }), - }; - }, - }; - }, - }; - }, - resolveModelFile: async () => "/fake/model.gguf", - LlamaLogLevel: { error: 0 }, - } as never); - - const { createEmbeddingProvider } = await import("./embeddings.js"); - - const result = await createEmbeddingProvider({ - config: {} as never, - provider: "local", - model: "", - fallback: "none", - }); - - const provider = requireProvider(result); const [queryA, batch, queryB] = await Promise.all([ provider.embedQuery("query-a"), provider.embedBatch(["batch-a", "batch-b"]), diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 19bd1f5923b..c670d8deb1b 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SecretInput } from "../config/types.secrets.js"; import { encodePairingSetupCode, resolvePairingSetupFromConfig } from "./setup-code.js"; describe("pairing setup code", () => { @@ -71,7 +72,7 @@ describe("pairing setup code", () => { }, { env: { - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret }, }, ); @@ -103,7 +104,7 @@ describe("pairing setup code", () => { }, { env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret }, }, ); @@ -204,15 +205,13 @@ describe("pairing setup code", () => { ).rejects.toThrow(/MISSING_GW_TOKEN/i); }); - it("uses password env in inferred mode without resolving token SecretRef", async () => { - const resolved = await resolvePairingSetupFromConfig( + async function resolveInferredModeWithPasswordEnv(token: SecretInput) { + return await resolvePairingSetupFromConfig( { gateway: { bind: "custom", customBindHost: "gateway.local", - auth: { - token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" }, - }, + auth: { token }, }, secrets: { providers: { @@ -222,10 +221,18 @@ describe("pairing setup code", () => { }, { env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", + OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret }, }, ); + } + + it("uses password env in inferred mode without resolving token SecretRef", async () => { + const resolved = await resolveInferredModeWithPasswordEnv({ + source: "env", + provider: "default", + id: "MISSING_GW_TOKEN", + }); expect(resolved.ok).toBe(true); if (!resolved.ok) { @@ -236,27 +243,7 @@ describe("pairing setup code", () => { }); it("does not treat env-template token as plaintext in inferred mode", async () => { - const resolved = await resolvePairingSetupFromConfig( - { - gateway: { - bind: "custom", - customBindHost: "gateway.local", - auth: { - token: "${MISSING_GW_TOKEN}", - }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - }, - { - env: { - OPENCLAW_GATEWAY_PASSWORD: "password-from-env", - }, - }, - ); + const resolved = await resolveInferredModeWithPasswordEnv("${MISSING_GW_TOKEN}"); expect(resolved.ok).toBe(true); if (!resolved.ok) { @@ -288,7 +275,7 @@ describe("pairing setup code", () => { { env: { GW_TOKEN: "resolved-token", - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret }, }, ), @@ -315,7 +302,7 @@ describe("pairing setup code", () => { }, { env: { - GW_PASSWORD: "resolved-password", + GW_PASSWORD: "resolved-password", // pragma: allowlist secret }, }, ), diff --git a/src/plugin-sdk/bluebubbles.ts b/src/plugin-sdk/bluebubbles.ts index 8489d4cb892..bb67d56878e 100644 --- a/src/plugin-sdk/bluebubbles.ts +++ b/src/plugin-sdk/bluebubbles.ts @@ -61,6 +61,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js"; diff --git a/src/plugin-sdk/channel-plugin-common.ts b/src/plugin-sdk/channel-plugin-common.ts new file mode 100644 index 00000000000..59c347c8f0c --- /dev/null +++ b/src/plugin-sdk/channel-plugin-common.ts @@ -0,0 +1,21 @@ +export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; + +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; + +export { getChatChannelMeta } from "../channels/registry.js"; diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index f9c4b6051df..d0408c604bf 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -1,28 +1,8 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { InspectedDiscordAccount } from "../discord/account-inspect.js"; export type { ResolvedDiscordAccount } from "../discord/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listDiscordAccountIds, diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 300daefc983..360623d9e9c 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -43,6 +43,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { installRequestBodyLimitGuard } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index 44dfbd4a149..dd181fee26c 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -1,25 +1,5 @@ -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ResolvedIMessageAccount } from "../imessage/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 63712fc8d71..b029062e28a 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -73,6 +73,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; diff --git a/src/plugin-sdk/mattermost.ts b/src/plugin-sdk/mattermost.ts index 12c18efea78..9ad22e60284 100644 --- a/src/plugin-sdk/mattermost.ts +++ b/src/plugin-sdk/mattermost.ts @@ -63,6 +63,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { BlockStreamingCoalesceSchema, DmPolicySchema, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 14d633a4c85..03116a7864b 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -51,6 +51,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { BlockStreamingCoalesceSchema, diff --git a/src/plugin-sdk/secret-input-schema.ts b/src/plugin-sdk/secret-input-schema.ts new file mode 100644 index 00000000000..d5eb3a0767e --- /dev/null +++ b/src/plugin-sdk/secret-input-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export function buildSecretInputSchema() { + return z.union([ + z.string(), + z.object({ + source: z.enum(["env", "file", "exec"]), + provider: z.string().min(1), + id: z.string().min(1), + }), + ]); +} diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index d15d35ee1dc..32f291913a5 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -1,26 +1,6 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ResolvedSignalAccount } from "../signal/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index b0df1329bb9..debb4b75fea 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -1,27 +1,7 @@ -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { InspectedSlackAccount } from "../slack/account-inspect.js"; export type { ResolvedSlackAccount } from "../slack/accounts.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { OpenClawPluginApi } from "../plugins/types.js"; - -export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; - -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; - -export { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../channels/plugins/setup-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; - -export { getChatChannelMeta } from "../channels/registry.js"; +export * from "./channel-plugin-common.js"; export { listSlackAccountIds, resolveDefaultSlackAccountId, diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 440cffd0de9..852c6f17f0c 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -48,6 +48,7 @@ export { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; +export { buildSecretInputSchema } from "./secret-input-schema.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index a8e5ecd0cf8..55d14c7e6d0 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -72,7 +72,7 @@ async function createApplyFixture(): Promise { env: { OPENCLAW_STATE_DIR: paths.stateDir, OPENCLAW_CONFIG_PATH: paths.configPath, - OPENAI_API_KEY: "sk-live-env", + OPENAI_API_KEY: "sk-live-env", // pragma: allowlist secret }, }; } @@ -91,19 +91,19 @@ async function seedDefaultApplyFixture(fixture: ApplyFixture): Promise { "openai:default": { type: "api_key", provider: "openai", - key: "sk-openai-plaintext", + key: "sk-openai-plaintext", // pragma: allowlist secret }, }, }); await writeJsonFile(fixture.authJsonPath, { openai: { type: "api_key", - key: "sk-openai-plaintext", + key: "sk-openai-plaintext", // pragma: allowlist secret }, }); await fs.writeFile( fixture.envPath, - "OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n", + "OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n", // pragma: allowlist secret "utf8", ); } @@ -149,6 +149,18 @@ function createOpenAiProviderTarget(params?: { }; } +function createOpenAiProviderHeaderTarget(params?: { + path?: string; + pathSegments?: string[]; +}): SecretsApplyPlan["targets"][number] { + return { + type: "models.providers.headers", + path: params?.path ?? "models.providers.openai.headers.x-api-key", + ...(params?.pathSegments ? { pathSegments: params.pathSegments } : {}), + ref: OPENAI_API_KEY_ENV_REF, + }; +} + function createOneWayScrubOptions(): NonNullable { return { scrubEnv: true, @@ -357,7 +369,7 @@ describe("secrets apply", () => { entries: { "qa-secret-test": { enabled: true, - apiKey: "sk-skill-plaintext", + apiKey: "sk-skill-plaintext", // pragma: allowlist secret }, }, }, @@ -394,7 +406,7 @@ describe("secrets apply", () => { `${JSON.stringify( { talk: { - apiKey: "sk-talk-plaintext", + apiKey: "sk-talk-plaintext", // pragma: allowlist secret }, }, null, @@ -436,6 +448,47 @@ describe("secrets apply", () => { }); }); + it("applies model provider header targets", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + ...createOpenAiProviderConfig(), + headers: { + "x-api-key": "sk-header-plaintext", + }, + }, + }, + }, + }); + + const plan = createPlan({ + targets: [ + createOpenAiProviderHeaderTarget({ + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + }), + ], + options: { + scrubEnv: false, + scrubAuthProfilesForProviderTargets: false, + scrubLegacyAuthJson: false, + }, + }); + + const nextConfig = await applyPlanAndReadConfig<{ + models?: { + providers?: { + openai?: { + headers?: Record; + }; + }; + }; + }>(fixture, plan); + expect(nextConfig.models?.providers?.openai?.headers?.["x-api-key"]).toEqual( + OPENAI_API_KEY_ENV_REF, + ); + }); + it("applies array-indexed targets for agent memory search", async () => { await fs.writeFile( fixture.configPath, @@ -447,7 +500,7 @@ describe("secrets apply", () => { id: "main", memorySearch: { remote: { - apiKey: "sk-memory-plaintext", + apiKey: "sk-memory-plaintext", // pragma: allowlist secret }, }, }, @@ -480,7 +533,7 @@ describe("secrets apply", () => { }, }; - fixture.env.MEMORY_REMOTE_API_KEY = "sk-memory-live-env"; + fixture.env.MEMORY_REMOTE_API_KEY = "sk-memory-live-env"; // pragma: allowlist secret const result = await runSecretsApply({ plan, env: fixture.env, write: true }); expect(result.changed).toBe(true); diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index cd85d84d3d8..b797494d54a 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -10,10 +10,13 @@ type AuditFixture = { configPath: string; authStorePath: string; authJsonPath: string; + modelsPath: string; envPath: string; env: NodeJS.ProcessEnv; }; +const OPENAI_API_KEY_MARKER = "OPENAI_API_KEY"; // pragma: allowlist secret + async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } @@ -27,9 +30,11 @@ function resolveRuntimePathEnv(): string { function hasFinding( report: Awaited>, - predicate: (entry: { code: string; file: string }) => boolean, + predicate: (entry: { code: string; file: string; jsonPath?: string }) => boolean, ): boolean { - return report.findings.some((entry) => predicate(entry as { code: string; file: string })); + return report.findings.some((entry) => + predicate(entry as { code: string; file: string; jsonPath?: string }), + ); } async function createAuditFixture(): Promise { @@ -38,6 +43,7 @@ async function createAuditFixture(): Promise { const configPath = path.join(stateDir, "openclaw.json"); const authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"); const authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json"); + const modelsPath = path.join(stateDir, "agents", "main", "agent", "models.json"); const envPath = path.join(stateDir, ".env"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -49,6 +55,7 @@ async function createAuditFixture(): Promise { configPath, authStorePath, authJsonPath, + modelsPath, envPath, env: { OPENCLAW_STATE_DIR: stateDir, @@ -64,7 +71,7 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { openai: { baseUrl: "https://api.openai.com/v1", api: "openai-completions", - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, models: [{ id: "gpt-5", name: "gpt-5" }], }, }; @@ -85,7 +92,21 @@ async function seedAuditFixture(fixture: AuditFixture): Promise { version: 1, profiles: Object.fromEntries(seededProfiles), }); - await fs.writeFile(fixture.envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8"); + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + await fs.writeFile( + fixture.envPath, + `${OPENAI_API_KEY_MARKER}=sk-openai-plaintext\n`, // pragma: allowlist secret + "utf8", + ); } describe("secrets audit", () => { @@ -254,4 +275,244 @@ describe("secrets audit", () => { const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length; expect(callCount).toBe(1); }); + + it("scans agent models.json files for plaintext provider apiKey values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "sk-models-plaintext", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + expect(report.filesScanned).toContain(fixture.modelsPath); + }); + + it("scans agent models.json files for plaintext provider header values", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: "Bearer sk-header-plaintext", // pragma: allowlist secret + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + + it("does not flag non-sensitive routing headers in models.json", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + "X-Proxy-Region": "us-west", + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); + }); + + it("does not flag models.json marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(false); + }); + + it("flags arbitrary all-caps models.json apiKey values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: "ALLCAPS_SAMPLE", // pragma: allowlist secret + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.apiKey", + ), + ).toBe(true); + }); + + it("does not flag models.json header marker values as plaintext", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: "secretref-env:OPENAI_HEADER_TOKEN", // pragma: allowlist secret + "x-managed-token": "secretref-managed", // pragma: allowlist secret + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(false); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.x-managed-token", + ), + ).toBe(false); + }); + + it("reports unresolved models.json SecretRef objects in provider headers", async () => { + await writeJsonFile(fixture.modelsPath, { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: OPENAI_API_KEY_MARKER, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_HEADER_TOKEN", // pragma: allowlist secret + }, + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "REF_UNRESOLVED" && + entry.file === fixture.modelsPath && + entry.jsonPath === "providers.openai.headers.Authorization", + ), + ).toBe(true); + }); + + it("reports malformed models.json as unresolved findings", async () => { + await fs.writeFile(fixture.modelsPath, "{bad-json", "utf8"); + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => entry.code === "REF_UNRESOLVED" && entry.file === fixture.modelsPath, + ), + ).toBe(true); + }); + + it("does not flag non-sensitive routing headers in openclaw config", async () => { + await writeJsonFile(fixture.configPath, { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + apiKey: { source: "env", provider: "default", id: OPENAI_API_KEY_MARKER }, + headers: { + "X-Proxy-Region": "us-west", + }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + await writeJsonFile(fixture.authStorePath, { + version: 1, + profiles: {}, + }); + await fs.writeFile(fixture.envPath, "", "utf8"); + + const report = await runSecretsAudit({ env: fixture.env }); + expect( + hasFinding( + report, + (entry) => + entry.code === "PLAINTEXT_FOUND" && + entry.file === fixture.configPath && + entry.jsonPath === "models.providers.openai.headers.X-Proxy-Region", + ), + ).toBe(false); + }); }); diff --git a/src/secrets/audit.ts b/src/secrets/audit.ts index 132ea4ac431..3215b3ce855 100644 --- a/src/secrets/audit.ts +++ b/src/secrets/audit.ts @@ -1,8 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + isNonSecretApiKeyMarker, + isSecretRefHeaderValueMarker, +} from "../agents/model-auth-markers.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { resolveStateDir, type OpenClawConfig } from "../config/config.js"; +import { coerceSecretRef } from "../config/types.secrets.js"; import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; @@ -23,6 +28,7 @@ import { import { isNonEmptyString, isRecord } from "./shared.js"; import { describeUnknownError } from "./shared.js"; import { + listAgentModelsJsonPaths, listAuthProfileStorePaths, listLegacyAuthJsonPaths, parseEnvAssignmentValue, @@ -91,6 +97,40 @@ type AuditCollector = { }; const REF_RESOLVE_FALLBACK_CONCURRENCY = 8; +const ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES = new Set([ + "authorization", + "proxy-authorization", + "x-api-key", + "api-key", + "apikey", + "x-auth-token", + "auth-token", + "x-access-token", + "access-token", + "x-secret-key", + "secret-key", +]); +const SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS = [ + "api-key", + "apikey", + "token", + "secret", + "password", + "credential", +]; + +function isLikelySensitiveModelProviderHeaderName(value: string): boolean { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (ALWAYS_SENSITIVE_MODEL_PROVIDER_HEADER_NAMES.has(normalized)) { + return true; + } + return SENSITIVE_MODEL_PROVIDER_HEADER_NAME_FRAGMENTS.some((fragment) => + normalized.includes(fragment), + ); +} function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void { collector.findings.push(finding); @@ -192,6 +232,12 @@ function collectConfigSecrets(params: { target.value, target.entry.expectedResolvedValue, ); + if ( + target.entry.id === "models.providers.*.headers.*" && + !isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "") + ) { + continue; + } if (!hasPlaintext) { continue; } @@ -315,6 +361,93 @@ function collectAuthJsonResidue(params: { stateDir: string; collector: AuditColl } } +function collectModelsJsonSecrets(params: { + modelsJsonPath: string; + collector: AuditCollector; +}): void { + if (!fs.existsSync(params.modelsJsonPath)) { + return; + } + params.collector.filesScanned.add(params.modelsJsonPath); + const parsedResult = readJsonObjectIfExists(params.modelsJsonPath); + if (parsedResult.error) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: "", + message: `Invalid JSON in models.json: ${parsedResult.error}`, + }); + return; + } + const parsed = parsedResult.value; + if (!parsed || !isRecord(parsed.providers)) { + return; + } + for (const [providerId, providerValue] of Object.entries(parsed.providers)) { + if (!isRecord(providerValue)) { + continue; + } + const apiKey = providerValue.apiKey; + if (coerceSecretRef(apiKey)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json contains an unresolved SecretRef object; regenerate models.json.", + provider: providerId, + }); + } else if (isNonEmptyString(apiKey) && !isNonSecretApiKeyMarker(apiKey)) { + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: `providers.${providerId}.apiKey`, + message: "models.json provider apiKey is stored as plaintext.", + provider: providerId, + }); + } + + const headers = isRecord(providerValue.headers) ? providerValue.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + const headerPath = `providers.${providerId}.headers.${headerKey}`; + if (coerceSecretRef(headerValue)) { + addFinding(params.collector, { + code: "REF_UNRESOLVED", + severity: "error", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: + "models.json contains an unresolved SecretRef object for provider headers; regenerate models.json.", + provider: providerId, + }); + continue; + } + if (!isNonEmptyString(headerValue)) { + continue; + } + if (isSecretRefHeaderValueMarker(headerValue)) { + continue; + } + if (!isLikelySensitiveModelProviderHeaderName(headerKey)) { + continue; + } + addFinding(params.collector, { + code: "PLAINTEXT_FOUND", + severity: "warn", + file: params.modelsJsonPath, + jsonPath: headerPath, + message: "models.json provider header value is stored as plaintext.", + provider: providerId, + }); + } + } +} + async function collectUnresolvedRefFindings(params: { collector: AuditCollector; config: OpenClawConfig; @@ -497,6 +630,12 @@ export async function runSecretsAudit( defaults, }); } + for (const modelsJsonPath of listAgentModelsJsonPaths(config, stateDir)) { + collectModelsJsonSecrets({ + modelsJsonPath, + collector, + }); + } await collectUnresolvedRefFindings({ collector, config, diff --git a/src/secrets/command-config.test.ts b/src/secrets/command-config.test.ts index a5e4abaf793..259916efcb7 100644 --- a/src/secrets/command-config.test.ts +++ b/src/secrets/command-config.test.ts @@ -11,7 +11,7 @@ describe("collectCommandSecretAssignmentsFromSnapshot", () => { } as unknown as OpenClawConfig; const resolvedConfig = { talk: { - apiKey: "talk-key", + apiKey: "talk-key", // pragma: allowlist secret }, } as unknown as OpenClawConfig; diff --git a/src/secrets/configure-plan.test.ts b/src/secrets/configure-plan.test.ts index bdc8b4d88fd..d8b360becbe 100644 --- a/src/secrets/configure-plan.test.ts +++ b/src/secrets/configure-plan.test.ts @@ -12,11 +12,11 @@ describe("secrets configure plan helpers", () => { it("builds configure candidates from supported configure targets", () => { const config = { talk: { - apiKey: "plain", + apiKey: "plain", // pragma: allowlist secret }, channels: { telegram: { - botToken: "token", + botToken: "token", // pragma: allowlist secret }, }, } as OpenClawConfig; @@ -125,7 +125,7 @@ describe("secrets configure plan helpers", () => { existingRef: { source: "env", provider: "default", - id: "OPENAI_API_KEY", + id: "OPENAI_API_KEY", // pragma: allowlist secret }, }), ]), @@ -139,15 +139,15 @@ describe("secrets configure plan helpers", () => { provider: "elevenlabs", providers: { elevenlabs: { - apiKey: "demo-talk-key", + apiKey: "demo-talk-key", // pragma: allowlist secret }, }, - apiKey: "demo-talk-key", + apiKey: "demo-talk-key", // pragma: allowlist secret }, } as OpenClawConfig, authoredOpenClawConfig: { talk: { - apiKey: "demo-talk-key", + apiKey: "demo-talk-key", // pragma: allowlist secret }, } as OpenClawConfig, }); diff --git a/src/secrets/path-utils.test.ts b/src/secrets/path-utils.test.ts index 4b13bcc299b..5c40fe2d9a8 100644 --- a/src/secrets/path-utils.test.ts +++ b/src/secrets/path-utils.test.ts @@ -51,7 +51,7 @@ describe("secrets path utils", () => { it("setPathExistingStrict updates an existing leaf", () => { const config = asConfig({ talk: { - apiKey: "old", + apiKey: "old", // pragma: allowlist secret }, }); const changed = setPathExistingStrict(config, ["talk", "apiKey"], "new"); @@ -69,7 +69,7 @@ describe("secrets path utils", () => { it("setPathCreateStrict leaves value unchanged when equal", () => { const config = asConfig({ talk: { - apiKey: "same", + apiKey: "same", // pragma: allowlist secret }, }); const changed = setPathCreateStrict(config, ["talk", "apiKey"], "same"); diff --git a/src/secrets/plan.test.ts b/src/secrets/plan.test.ts index 95071d549e1..01ee81ea551 100644 --- a/src/secrets/plan.test.ts +++ b/src/secrets/plan.test.ts @@ -21,6 +21,22 @@ describe("secrets plan validation", () => { expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]); }); + it("accepts model provider header targets with wildcard-backed paths", () => { + const resolved = resolveValidatedPlanTarget({ + type: "models.providers.headers", + path: "models.providers.openai.headers.x-api-key", + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + providerId: "openai", + }); + expect(resolved?.pathSegments).toEqual([ + "models", + "providers", + "openai", + "headers", + "x-api-key", + ]); + }); + it("rejects target paths that do not match the registered shape", () => { const resolved = resolveValidatedPlanTarget({ type: "channels.telegram.botToken", diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 085573173cc..504331f0a96 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -10,6 +10,7 @@ import { isRecord } from "./shared.js"; type ProviderLike = { apiKey?: unknown; + headers?: unknown; enabled?: unknown; }; @@ -24,18 +25,37 @@ function collectModelProviderAssignments(params: { context: ResolverContext; }): void { for (const [providerId, provider] of Object.entries(params.providers)) { + const providerIsActive = provider.enabled !== false; collectSecretInputAssignment({ value: provider.apiKey, path: `models.providers.${providerId}.apiKey`, expected: "string", defaults: params.defaults, context: params.context, - active: provider.enabled !== false, + active: providerIsActive, inactiveReason: "provider is disabled.", apply: (value) => { provider.apiKey = value; }, }); + const headers = isRecord(provider.headers) ? provider.headers : undefined; + if (!headers) { + continue; + } + for (const [headerKey, headerValue] of Object.entries(headers)) { + collectSecretInputAssignment({ + value: headerValue, + path: `models.providers.${providerId}.headers.${headerKey}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: providerIsActive, + inactiveReason: "provider is disabled.", + apply: (value) => { + headers[headerKey] = value; + }, + }); + } } } diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 468963041b8..35d265a612d 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -27,7 +27,7 @@ function toConcretePathSegments(pathPattern: string): string[] { function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string): OpenClawConfig { const config = {} as OpenClawConfig; const refTargetPath = - entry.secretShape === "sibling_ref" && entry.refPathPattern + entry.secretShape === "sibling_ref" && entry.refPathPattern // pragma: allowlist secret ? entry.refPathPattern : entry.pathPattern; setPathCreateStrict(config, toConcretePathSegments(refTargetPath), { diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index e1ca5774a75..1d9189f843c 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -56,6 +56,13 @@ describe("secrets runtime snapshot", () => { openai: { baseUrl: "https://api.openai.com/v1", apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { + Authorization: { + source: "env", + provider: "default", + id: "OPENAI_PROVIDER_AUTH_HEADER", + }, + }, models: [], }, }, @@ -123,6 +130,7 @@ describe("secrets runtime snapshot", () => { config, env: { OPENAI_API_KEY: "sk-env-openai", // pragma: allowlist secret + OPENAI_PROVIDER_AUTH_HEADER: "Bearer sk-env-header", // pragma: allowlist secret GITHUB_TOKEN: "ghp-env-token", // pragma: allowlist secret REVIEW_SKILL_API_KEY: "sk-skill-ref", // pragma: allowlist secret MEMORY_REMOTE_API_KEY: "mem-ref-key", // pragma: allowlist secret @@ -162,6 +170,9 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai"); + expect(snapshot.config.models?.providers?.openai?.headers?.Authorization).toBe( + "Bearer sk-env-header", + ); expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref"); expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); expect(snapshot.config.talk?.apiKey).toBe("talk-ref-key"); diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index ccbfc544f6d..557f611c006 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; import { listAuthProfileStorePaths as listAuthProfileStorePathsFromAuthStorePaths } from "./auth-store-paths.js"; @@ -31,6 +32,32 @@ export function listLegacyAuthJsonPaths(stateDir: string): string[] { return out; } +export function listAgentModelsJsonPaths(config: OpenClawConfig, stateDir: string): string[] { + const paths = new Set(); + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + + const agentsRoot = path.join(resolveUserPath(stateDir), "agents"); + if (fs.existsSync(agentsRoot)) { + for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + paths.add(path.join(agentsRoot, entry.name, "agent", "models.json")); + } + } + + for (const agentId of listAgentIds(config)) { + if (agentId === "main") { + paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "models.json")); + continue; + } + const agentDir = resolveAgentDir(config, agentId); + paths.add(path.join(resolveUserPath(agentDir), "models.json")); + } + + return [...paths]; +} + export function readJsonObjectIfExists(filePath: string): { value: Record | null; error?: string; diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 61ccb1f9b66..3be4992d28f 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -642,6 +642,19 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ providerIdPathSegmentIndex: 2, trackProviderShadowing: true, }, + { + id: "models.providers.*.headers.*", + targetType: "models.providers.headers", + targetTypeAliases: ["models.providers.*.headers.*"], + configFile: "openclaw.json", + pathPattern: "models.providers.*.headers.*", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + providerIdPathSegmentIndex: 2, + }, { id: "skills.entries.*.apiKey", targetType: "skills.entries.apiKey", diff --git a/src/secrets/target-registry-pattern.test.ts b/src/secrets/target-registry-pattern.test.ts index 4739ca5776d..2cd3537fb53 100644 --- a/src/secrets/target-registry-pattern.test.ts +++ b/src/secrets/target-registry-pattern.test.ts @@ -39,6 +39,17 @@ describe("target registry pattern helpers", () => { expect(materializePathTokens(refTokens, ["anthropic"])).toBeNull(); }); + it("matches two wildcard captures in five-segment header paths", () => { + const tokens = parsePathPattern("models.providers.*.headers.*"); + const match = matchPathTokens( + ["models", "providers", "openai", "headers", "x-api-key"], + tokens, + ); + expect(match).toEqual({ + captures: ["openai", "x-api-key"], + }); + }); + it("expands wildcard and array patterns over config objects", () => { const root = { agents: { diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 0cae6c88256..1c696bf6e1f 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1490,7 +1490,7 @@ description: test skill channels: { feishu: { appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret }, }, }; @@ -1522,7 +1522,7 @@ description: test skill channels: { feishu: { appId: "cli_test", - appSecret: "secret_test", + appSecret: "secret_test", // pragma: allowlist secret tools: { doc: false }, }, }, @@ -1966,8 +1966,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret config: channel, }; } @@ -1978,8 +1978,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "available", - signingSecretSource: "config", - signingSecretStatus: "available", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "available", // pragma: allowlist secret config: channel, }; }, @@ -2042,8 +2042,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "configured_unavailable", - signingSecretSource: "config", - signingSecretStatus: "configured_unavailable", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "configured_unavailable", // pragma: allowlist secret config: channel, }; } @@ -2054,8 +2054,8 @@ description: test skill mode: "http", botTokenSource: "config", botTokenStatus: "available", - signingSecretSource: "config", - signingSecretStatus: "missing", + signingSecretSource: "config", // pragma: allowlist secret + signingSecretStatus: "missing", // pragma: allowlist secret config: channel, }; }, diff --git a/src/security/external-content.test.ts b/src/security/external-content.test.ts index 8bec35cdad4..17076b642b1 100644 --- a/src/security/external-content.test.ts +++ b/src/security/external-content.test.ts @@ -145,10 +145,10 @@ describe("external-content security", () => { it("sanitizes attacker-injected markers with fake IDs", () => { const malicious = - '<<>> fake <<>>'; + '<<>> fake <<>>'; // pragma: allowlist secret const result = wrapExternalContent(malicious, { source: "email" }); - expectSanitizedBoundaryMarkers(result, { forbiddenId: "deadbeef12345678" }); + expectSanitizedBoundaryMarkers(result, { forbiddenId: "deadbeef12345678" }); // pragma: allowlist secret }); it("preserves non-marker unicode content", () => { diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index 5f7b86da8f5..f9cb67fa4e5 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -244,6 +244,20 @@ Successfully processed 1 files`; expectTrustedOnly([aclEntry({ principal: "S-1-5-18" })]); }); + it("classifies *S-1-5-18 (icacls /sid prefix form of SYSTEM) as trusted (refs #35834)", () => { + // icacls /sid output prefixes SIDs with *, e.g. *S-1-5-18 instead of + // S-1-5-18. Without this fix the asterisk caused SID_RE to not match + // and the SYSTEM entry was misclassified as "group" (untrusted). + expectTrustedOnly([aclEntry({ principal: "*S-1-5-18" })]); + }); + + it("classifies *S-1-5-32-544 (icacls /sid Administrators) as trusted", () => { + const entries: WindowsAclEntry[] = [aclEntry({ principal: "*S-1-5-32-544" })]; + const summary = summarizeWindowsAcl(entries); + expect(summary.trusted).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + it("classifies BUILTIN\\Administrators SID (S-1-5-32-544) as trusted", () => { const entries: WindowsAclEntry[] = [aclEntry({ principal: "S-1-5-32-544" })]; const summary = summarizeWindowsAcl(entries); @@ -265,6 +279,21 @@ Successfully processed 1 files`; ); }); + it("does not trust *-prefixed Everyone via USERSID", () => { + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-1-0", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries, { USERSID: "*S-1-1-0" }); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.trusted).toHaveLength(0); + }); + it("classifies unknown SID as group (not world)", () => { const entries: WindowsAclEntry[] = [ { @@ -281,6 +310,53 @@ Successfully processed 1 files`; expect(summary.trusted).toHaveLength(0); }); + it("classifies Everyone SID (S-1-1-0) as world, not group", () => { + // When icacls is run with /sid, "Everyone" becomes *S-1-1-0. + // It must be classified as "world" to preserve security-audit severity. + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-1-0", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + + it("classifies Authenticated Users SID (S-1-5-11) as world, not group", () => { + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-5-11", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + + it("classifies BUILTIN\\Users SID (S-1-5-32-545) as world, not group", () => { + const entries: WindowsAclEntry[] = [ + { + principal: "*S-1-5-32-545", + rights: ["R"], + rawRights: "(R)", + canRead: true, + canWrite: false, + }, + ]; + const summary = summarizeWindowsAcl(entries); + expect(summary.untrustedWorld).toHaveLength(1); + expect(summary.untrustedGroup).toHaveLength(0); + }); + it("full scenario: SYSTEM SID + owner SID only → no findings", () => { const ownerSid = "S-1-5-21-1824257776-4070701511-781240313-1001"; const entries: WindowsAclEntry[] = [ @@ -319,7 +395,55 @@ Successfully processed 1 files`; exec: mockExec, }); expectInspectSuccess(result, 2); - expect(mockExec).toHaveBeenCalledWith("icacls", ["C:\\test\\file.txt"]); + // /sid is passed so that account names are printed as SIDs, making the + // audit locale-independent (fixes #35834). + expect(mockExec).toHaveBeenCalledWith("icacls", ["C:\\test\\file.txt", "/sid"]); + }); + + it("classifies *S-1-5-18 (SID form of SYSTEM from /sid) as trusted", async () => { + // When icacls is called with /sid it outputs *S-X-X-X instead of + // locale-dependent names like "NT AUTHORITY\\SYSTEM" or the Russian + // garbled equivalent. + const mockExec = vi.fn().mockResolvedValue({ + stdout: + "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)\n *S-1-5-32-544:(F)", + stderr: "", + }); + + const result = await inspectWindowsAcl("C:\\test\\file.txt", { + exec: mockExec, + env: { USERSID: "S-1-5-21-111-222-333-1001" }, + }); + expectInspectSuccess(result, 3); + // All three entries (current user, SYSTEM, Administrators) must be trusted. + expect(result.trusted).toHaveLength(3); + expect(result.untrustedGroup).toHaveLength(0); + expect(result.untrustedWorld).toHaveLength(0); + }); + + it("resolves current user SID via whoami when USERSID is missing", async () => { + const mockExec = vi + .fn() + .mockResolvedValueOnce({ + stdout: + "C:\\test\\file.txt *S-1-5-21-111-222-333-1001:(F)\n *S-1-5-18:(F)", + stderr: "", + }) + .mockResolvedValueOnce({ + stdout: '"mock-host\\\\MockUser","S-1-5-21-111-222-333-1001"\r\n', + stderr: "", + }); + + const result = await inspectWindowsAcl("C:\\test\\file.txt", { + exec: mockExec, + env: { USERNAME: "MockUser", USERDOMAIN: "mock-host" }, + }); + + expectInspectSuccess(result, 2); + expect(result.trusted).toHaveLength(2); + expect(result.untrustedGroup).toHaveLength(0); + expect(mockExec).toHaveBeenNthCalledWith(1, "icacls", ["C:\\test\\file.txt", "/sid"]); + expect(mockExec).toHaveBeenNthCalledWith(2, "whoami", ["/user", "/fo", "csv", "/nh"]); }); it("returns error state on exec failure", async () => { diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts index 64e415cca32..c7580bbc42c 100644 --- a/src/security/windows-acl.ts +++ b/src/security/windows-acl.ts @@ -42,12 +42,20 @@ const TRUSTED_BASE = new Set([ const WORLD_SUFFIXES = ["\\users", "\\authenticated users"]; const TRUSTED_SUFFIXES = ["\\administrators", "\\system", "\\système"]; -const SID_RE = /^s-\d+-\d+(-\d+)+$/i; +// Accept an optional leading * which icacls prefixes to SIDs when invoked with /sid +// (e.g. *S-1-5-18 instead of S-1-5-18). +const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i; const TRUSTED_SIDS = new Set([ "s-1-5-18", "s-1-5-32-544", "s-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", ]); +// SIDs for world-equivalent principals that icacls /sid emits as raw SIDs. +// Without this list these would be classified as "group" instead of "world". +// S-1-1-0 Everyone +// S-1-5-11 Authenticated Users +// S-1-5-32-545 BUILTIN\Users +const WORLD_SIDS = new Set(["s-1-1-0", "s-1-5-11", "s-1-5-32-545"]); const STATUS_PREFIXES = [ "successfully processed", "processed", @@ -57,6 +65,11 @@ const STATUS_PREFIXES = [ const normalize = (value: string) => value.trim().toLowerCase(); +function normalizeSid(value: string): string { + const normalized = normalize(value); + return normalized.startsWith("*") ? normalized.slice(1) : normalized; +} + export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null { const username = env?.USERNAME?.trim() || os.userInfo().username?.trim(); if (!username) { @@ -77,7 +90,7 @@ function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set { trusted.add(normalize(userOnly)); } } - const userSid = normalize(env?.USERSID ?? ""); + const userSid = normalizeSid(env?.USERSID ?? ""); if (userSid && SID_RE.test(userSid)) { trusted.add(userSid); } @@ -91,7 +104,18 @@ function classifyPrincipal( const normalized = normalize(principal); if (SID_RE.test(normalized)) { - return TRUSTED_SIDS.has(normalized) || trustedPrincipals.has(normalized) ? "trusted" : "group"; + // Strip the leading * that icacls /sid prefixes to SIDs before lookup. + const sid = normalizeSid(normalized); + // World-equivalent SIDs must be classified as "world", not "group", so + // that callers applying world-write policies catch everyone/authenticated- + // users entries the same way they would catch the human-readable names. + if (WORLD_SIDS.has(sid)) { + return "world"; + } + if (TRUSTED_SIDS.has(sid) || trustedPrincipals.has(sid)) { + return "trusted"; + } + return "group"; } if ( @@ -243,16 +267,44 @@ export function summarizeWindowsAcl( return { trusted, untrustedWorld, untrustedGroup }; } +async function resolveCurrentUserSid(exec: ExecFn): Promise { + try { + const { stdout, stderr } = await exec("whoami", ["/user", "/fo", "csv", "/nh"]); + const match = `${stdout}\n${stderr}`.match(/\*?S-\d+-\d+(?:-\d+)+/i); + return match ? normalizeSid(match[0]) : null; + } catch { + return null; + } +} + export async function inspectWindowsAcl( targetPath: string, opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn }, ): Promise { const exec = opts?.exec ?? runExec; try { - const { stdout, stderr } = await exec("icacls", [targetPath]); + // /sid outputs security identifiers (e.g. *S-1-5-18) instead of locale- + // dependent account names so the audit works correctly on non-English + // Windows (Russian, Chinese, etc.) where icacls prints Cyrillic / CJK + // characters that may be garbled when Node reads them in the wrong code + // page. Fixes #35834. + const { stdout, stderr } = await exec("icacls", [targetPath, "/sid"]); const output = `${stdout}\n${stderr}`.trim(); const entries = parseIcaclsOutput(output, targetPath); - const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env); + let effectiveEnv = opts?.env; + let { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv); + + const needsUserSidResolution = + !effectiveEnv?.USERSID && + untrustedGroup.some((entry) => SID_RE.test(normalize(entry.principal))); + if (needsUserSidResolution) { + const currentUserSid = await resolveCurrentUserSid(exec); + if (currentUserSid) { + effectiveEnv = { ...effectiveEnv, USERSID: currentUserSid }; + ({ trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, effectiveEnv)); + } + } + return { ok: true, entries, trusted, untrustedWorld, untrustedGroup }; } catch (err) { return { diff --git a/src/signal/identity.test.ts b/src/signal/identity.test.ts index b6f35ab6471..a09f81910c6 100644 --- a/src/signal/identity.test.ts +++ b/src/signal/identity.test.ts @@ -12,7 +12,7 @@ describe("looksLikeUuid", () => { }); it("accepts compact UUIDs", () => { - expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeUuid("123e4567e89b12d3a456426614174000")).toBe(true); // pragma: allowlist secret }); it("accepts uuid-like hex values with letters", () => { diff --git a/src/slack/account-inspect.ts b/src/slack/account-inspect.ts index f29d718aa28..34b4a13fb23 100644 --- a/src/slack/account-inspect.ts +++ b/src/slack/account-inspect.ts @@ -1,9 +1,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; import type { SlackAccountConfig } from "../config/types.slack.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultSlackAccountId, type SlackTokenSource } from "./accounts.js"; +import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; +import { + mergeSlackAccountConfig, + resolveDefaultSlackAccountId, + type SlackTokenSource, +} from "./accounts.js"; export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing"; @@ -26,33 +30,7 @@ export type InspectedSlackAccount = { userTokenStatus: SlackCredentialStatus; configured: boolean; config: SlackAccountConfig; - groupPolicy?: SlackAccountConfig["groupPolicy"]; - textChunkLimit?: SlackAccountConfig["textChunkLimit"]; - mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; - reactionNotifications?: SlackAccountConfig["reactionNotifications"]; - reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; - replyToMode?: SlackAccountConfig["replyToMode"]; - replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; - actions?: SlackAccountConfig["actions"]; - slashCommand?: SlackAccountConfig["slashCommand"]; - dm?: SlackAccountConfig["dm"]; - channels?: SlackAccountConfig["channels"]; -}; - -function resolveSlackAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): SlackAccountConfig | undefined { - return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId); -} - -function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig { - const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { - accounts?: unknown; - }; - const account = resolveSlackAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account }; -} +} & SlackAccountSurfaceFields; function inspectSlackToken(value: unknown): { token?: string; diff --git a/src/slack/account-surface-fields.ts b/src/slack/account-surface-fields.ts new file mode 100644 index 00000000000..8e2293e213a --- /dev/null +++ b/src/slack/account-surface-fields.ts @@ -0,0 +1,15 @@ +import type { SlackAccountConfig } from "../config/types.js"; + +export type SlackAccountSurfaceFields = { + groupPolicy?: SlackAccountConfig["groupPolicy"]; + textChunkLimit?: SlackAccountConfig["textChunkLimit"]; + mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; + reactionNotifications?: SlackAccountConfig["reactionNotifications"]; + reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; + replyToMode?: SlackAccountConfig["replyToMode"]; + replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; + actions?: SlackAccountConfig["actions"]; + slashCommand?: SlackAccountConfig["slashCommand"]; + dm?: SlackAccountConfig["dm"]; + channels?: SlackAccountConfig["channels"]; +}; diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index b997a2cccd7..6e5aed59fa2 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; export type SlackTokenSource = "env" | "config" | "none"; @@ -19,18 +20,7 @@ export type ResolvedSlackAccount = { appTokenSource: SlackTokenSource; userTokenSource: SlackTokenSource; config: SlackAccountConfig; - groupPolicy?: SlackAccountConfig["groupPolicy"]; - textChunkLimit?: SlackAccountConfig["textChunkLimit"]; - mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; - reactionNotifications?: SlackAccountConfig["reactionNotifications"]; - reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; - replyToMode?: SlackAccountConfig["replyToMode"]; - replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; - actions?: SlackAccountConfig["actions"]; - slashCommand?: SlackAccountConfig["slashCommand"]; - dm?: SlackAccountConfig["dm"]; - channels?: SlackAccountConfig["channels"]; -}; +} & SlackAccountSurfaceFields; const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack"); export const listSlackAccountIds = listAccountIds; @@ -43,7 +33,10 @@ function resolveAccountConfig( return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId); } -function mergeSlackAccountConfig(cfg: OpenClawConfig, accountId: string): SlackAccountConfig { +export function mergeSlackAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): SlackAccountConfig { const { accounts: _ignored, ...base } = (cfg.channels?.slack ?? {}) as SlackAccountConfig & { accounts?: unknown; }; diff --git a/src/slack/monitor/message-handler.app-mention-race.test.ts b/src/slack/monitor/message-handler.app-mention-race.test.ts index c84b6514b43..8c6afb15a8b 100644 --- a/src/slack/monitor/message-handler.app-mention-race.test.ts +++ b/src/slack/monitor/message-handler.app-mention-race.test.ts @@ -67,6 +67,55 @@ function createMarkMessageSeen() { }; } +function createTestHandler() { + return createSlackMessageHandler({ + ctx: { + cfg: {}, + accountId: "default", + app: { client: {} }, + runtime: {}, + markMessageSeen: createMarkMessageSeen(), + } as Parameters[0]["ctx"], + account: { accountId: "default" } as Parameters[0]["account"], + }); +} + +function createSlackEvent(params: { type: "message" | "app_mention"; ts: string; text: string }) { + return { type: params.type, channel: "C1", ts: params.ts, text: params.text } as never; +} + +async function sendMessageEvent(handler: ReturnType, ts: string) { + await handler(createSlackEvent({ type: "message", ts, text: "hello" }), { source: "message" }); +} + +async function sendMentionEvent(handler: ReturnType, ts: string) { + await handler(createSlackEvent({ type: "app_mention", ts, text: "<@U_BOT> hello" }), { + source: "app_mention", + wasMentioned: true, + }); +} + +async function createInFlightMessageScenario(ts: string) { + let resolveMessagePrepare: ((value: unknown) => void) | undefined; + const messagePrepare = new Promise((resolve) => { + resolveMessagePrepare = resolve; + }); + prepareSlackMessageMock.mockImplementation(async ({ opts }) => { + if (opts.source === "message") { + return messagePrepare; + } + return { ctxPayload: {} }; + }); + + const handler = createTestHandler(); + const messagePending = handler(createSlackEvent({ type: "message", ts, text: "hello" }), { + source: "message", + }); + await Promise.resolve(); + + return { handler, messagePending, resolveMessagePrepare }; +} + describe("createSlackMessageHandler app_mention race handling", () => { beforeEach(() => { prepareSlackMessageMock.mockReset(); @@ -81,144 +130,36 @@ describe("createSlackMessageHandler app_mention race handling", () => { return { ctxPayload: {} }; }); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); + const handler = createTestHandler(); - await handler( - { type: "message", channel: "C1", ts: "1700000000.000100", text: "hello" } as never, - { source: "message" }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000100", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000100", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMessageEvent(handler, "1700000000.000100"); + await sendMentionEvent(handler, "1700000000.000100"); + await sendMentionEvent(handler, "1700000000.000100"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(2); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); }); it("allows app_mention while message handling is still in-flight, then keeps later duplicates deduped", async () => { - let resolveMessagePrepare: ((value: unknown) => void) | undefined; - const messagePrepare = new Promise((resolve) => { - resolveMessagePrepare = resolve; - }); - prepareSlackMessageMock.mockImplementation(async ({ opts }) => { - if (opts.source === "message") { - return messagePrepare; - } - return { ctxPayload: {} }; - }); + const { handler, messagePending, resolveMessagePrepare } = + await createInFlightMessageScenario("1700000000.000150"); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); - - const messagePending = handler( - { type: "message", channel: "C1", ts: "1700000000.000150", text: "hello" } as never, - { source: "message" }, - ); - await Promise.resolve(); - - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000150", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000150"); resolveMessagePrepare?.(null); await messagePending; - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000150", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000150"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(2); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); }); it("suppresses message dispatch when app_mention already dispatched during in-flight race", async () => { - let resolveMessagePrepare: ((value: unknown) => void) | undefined; - const messagePrepare = new Promise((resolve) => { - resolveMessagePrepare = resolve; - }); - prepareSlackMessageMock.mockImplementation(async ({ opts }) => { - if (opts.source === "message") { - return messagePrepare; - } - return { ctxPayload: {} }; - }); + const { handler, messagePending, resolveMessagePrepare } = + await createInFlightMessageScenario("1700000000.000175"); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); - - const messagePending = handler( - { type: "message", channel: "C1", ts: "1700000000.000175", text: "hello" } as never, - { source: "message" }, - ); - await Promise.resolve(); - - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000175", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMentionEvent(handler, "1700000000.000175"); resolveMessagePrepare?.({ ctxPayload: {} }); await messagePending; @@ -230,32 +171,10 @@ describe("createSlackMessageHandler app_mention race handling", () => { it("keeps app_mention deduped when message event already dispatched", async () => { prepareSlackMessageMock.mockResolvedValue({ ctxPayload: {} }); - const handler = createSlackMessageHandler({ - ctx: { - cfg: {}, - accountId: "default", - app: { client: {} }, - runtime: {}, - markMessageSeen: createMarkMessageSeen(), - } as Parameters[0]["ctx"], - account: { accountId: "default" } as Parameters< - typeof createSlackMessageHandler - >[0]["account"], - }); + const handler = createTestHandler(); - await handler( - { type: "message", channel: "C1", ts: "1700000000.000200", text: "hello" } as never, - { source: "message" }, - ); - await handler( - { - type: "app_mention", - channel: "C1", - ts: "1700000000.000200", - text: "<@U_BOT> hello", - } as never, - { source: "app_mention", wasMentioned: true }, - ); + await sendMessageEvent(handler, "1700000000.000200"); + await sendMentionEvent(handler, "1700000000.000200"); expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1); expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1); diff --git a/src/slack/monitor/message-handler/prepare.test.ts b/src/slack/monitor/message-handler/prepare.test.ts index a5bdebc1e2d..a5007831a2b 100644 --- a/src/slack/monitor/message-handler/prepare.test.ts +++ b/src/slack/monitor/message-handler/prepare.test.ts @@ -7,12 +7,11 @@ import { expectInboundContextContract } from "../../../../test/helpers/inbound-c import type { OpenClawConfig } from "../../../config/config.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; -import type { RuntimeEnv } from "../../../runtime.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackMonitorContext } from "../context.js"; -import { createSlackMonitorContext } from "../context.js"; import { prepareSlackMessage } from "./prepare.js"; +import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js"; describe("slack prepareSlackMessage inbound contract", () => { let fixtureRoot = ""; @@ -38,53 +37,7 @@ describe("slack prepareSlackMessage inbound contract", () => { } }); - function createInboundSlackCtx(params: { - cfg: OpenClawConfig; - appClient?: App["client"]; - defaultRequireMention?: boolean; - replyToMode?: "off" | "all"; - channelsConfig?: Record; - }) { - return createSlackMonitorContext({ - cfg: params.cfg, - accountId: "default", - botToken: "token", - app: { client: params.appClient ?? {} } as App, - runtime: {} as RuntimeEnv, - botUserId: "B1", - teamId: "T1", - apiAppId: "A1", - historyLimit: 0, - sessionScope: "per-sender", - mainKey: "main", - dmEnabled: true, - dmPolicy: "open", - allowFrom: [], - allowNameMatching: false, - groupDmEnabled: true, - groupDmChannels: [], - defaultRequireMention: params.defaultRequireMention ?? true, - channelsConfig: params.channelsConfig, - groupPolicy: "open", - useAccessGroups: false, - reactionMode: "off", - reactionAllowlist: [], - replyToMode: params.replyToMode ?? "off", - threadHistoryScope: "thread", - threadInheritParent: false, - slashCommand: { - enabled: false, - name: "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }, - textLimit: 4000, - ackReactionScope: "group-mentions", - typingReaction: "", - mediaMaxBytes: 1024, - removeAckAfterReply: false, - }); - } + const createInboundSlackCtx = createInboundSlackTestContext; function createDefaultSlackCtx() { const slackCtx = createInboundSlackCtx({ @@ -115,19 +68,7 @@ describe("slack prepareSlackMessage inbound contract", () => { }); } - function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { - return { - accountId: "default", - enabled: true, - botTokenSource: "config", - appTokenSource: "config", - userTokenSource: "none", - config, - replyToMode: config.replyToMode, - replyToModeByChatType: config.replyToModeByChatType, - dm: config.dm, - }; - } + const createSlackAccount = createSlackTestAccount; function createSlackMessage(overrides: Partial): SlackMessageEvent { return { diff --git a/src/slack/monitor/monitor.test.ts b/src/slack/monitor/monitor.test.ts index d6e819ca46d..748be0a212a 100644 --- a/src/slack/monitor/monitor.test.ts +++ b/src/slack/monitor/monitor.test.ts @@ -65,7 +65,7 @@ describe("resolveSlackChannelConfig", () => { // Slack always delivers channel IDs in uppercase (e.g. C0ABC12345). // Users commonly copy them in lowercase from docs or older CLI output. const res = resolveSlackChannelConfig({ - channelId: "C0ABC12345", + channelId: "C0ABC12345", // pragma: allowlist secret channels: { c0abc12345: { allow: true, requireMention: false } }, defaultRequireMention: true, }); @@ -75,7 +75,7 @@ describe("resolveSlackChannelConfig", () => { it("matches channel config key stored in uppercase when user types lowercase channel ID", () => { // Defensive: also handle the inverse direction. const res = resolveSlackChannelConfig({ - channelId: "c0abc12345", + channelId: "c0abc12345", // pragma: allowlist secret channels: { C0ABC12345: { allow: true, requireMention: false } }, defaultRequireMention: true, }); diff --git a/src/slack/monitor/provider.reconnect.test.ts b/src/slack/monitor/provider.reconnect.test.ts index 10fbab031a0..81beaa59576 100644 --- a/src/slack/monitor/provider.reconnect.test.ts +++ b/src/slack/monitor/provider.reconnect.test.ts @@ -38,6 +38,38 @@ describe("slack socket reconnect helpers", () => { ); }); + it("clears connected state when socket mode disconnects", () => { + const setStatus = vi.fn(); + const err = new Error("dns down"); + + __testing.publishSlackDisconnectedStatus(setStatus, err); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith({ + connected: false, + lastDisconnect: { + at: expect.any(Number), + error: "dns down", + }, + lastError: "dns down", + }); + }); + + it("clears connected state without error when socket mode disconnects cleanly", () => { + const setStatus = vi.fn(); + + __testing.publishSlackDisconnectedStatus(setStatus); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith({ + connected: false, + lastDisconnect: { + at: expect.any(Number), + }, + lastError: null, + }); + }); + it("resolves disconnect waiter on socket disconnect event", async () => { const client = new FakeEmitter(); const app = { receiver: { client } }; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 12ba1020268..8686eb51367 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -77,6 +77,22 @@ function publishSlackConnectedStatus(setStatus?: (next: Record) }); } +function publishSlackDisconnectedStatus( + setStatus?: (next: Record) => void, + error?: unknown, +) { + if (!setStatus) { + return; + } + const at = Date.now(); + const message = error ? formatUnknownError(error) : undefined; + setStatus({ + connected: false, + lastDisconnect: message ? { at, error: message } : { at }, + lastError: message ?? null, + }); +} + export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const cfg = opts.config ?? loadConfig(); const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); @@ -440,6 +456,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (opts.abortSignal?.aborted) { break; } + publishSlackDisconnectedStatus(opts.setStatus, disconnect.error); // Bail immediately on non-recoverable auth errors during reconnect too. if (disconnect.error && isNonRecoverableSlackAuthError(disconnect.error)) { @@ -495,6 +512,7 @@ export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js"; export const __testing = { publishSlackConnectedStatus, + publishSlackDisconnectedStatus, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, getSocketEmitter, diff --git a/src/telegram/account-inspect.ts b/src/telegram/account-inspect.ts index 5c50c7d7d67..305e410d39a 100644 --- a/src/telegram/account-inspect.ts +++ b/src/telegram/account-inspect.ts @@ -3,9 +3,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; import type { TelegramAccountConfig } from "../config/types.telegram.js"; import { resolveAccountWithDefaultFallback } from "../plugin-sdk/account-resolution.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDefaultTelegramAccountId } from "./accounts.js"; +import { + mergeTelegramAccountConfig, + resolveDefaultTelegramAccountId, + resolveTelegramAccountConfig, +} from "./accounts.js"; export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing"; @@ -20,31 +23,6 @@ export type InspectedTelegramAccount = { config: TelegramAccountConfig; }; -function resolveTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig | undefined { - const normalized = normalizeAccountId(accountId); - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); -} - -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { - const { - accounts: _ignored, - defaultAccount: _ignoredDefaultAccount, - groups: channelGroups, - ...base - } = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { - accounts?: unknown; - defaultAccount?: unknown; - }; - const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; - const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - const isMultiAccount = configuredAccountIds.length > 1; - const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); - return { ...base, ...account, groups }; -} - function inspectTokenFile(pathValue: unknown): { token: string; tokenSource: "tokenFile" | "none"; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index e3d86ec84b4..b8c656d1bfd 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -97,7 +97,7 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { return ids[0] ?? DEFAULT_ACCOUNT_ID; } -function resolveAccountConfig( +export function resolveTelegramAccountConfig( cfg: OpenClawConfig, accountId: string, ): TelegramAccountConfig | undefined { @@ -105,7 +105,10 @@ function resolveAccountConfig( return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); } -function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig { +export function mergeTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig { const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, @@ -115,7 +118,7 @@ function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): Tel accounts?: unknown; defaultAccount?: unknown; }; - const account = resolveAccountConfig(cfg, accountId) ?? {}; + const account = resolveTelegramAccountConfig(cfg, accountId) ?? {}; // In multi-account setups, channel-level `groups` must NOT be inherited by // accounts that don't have their own `groups` config. A bot that is not a @@ -138,7 +141,7 @@ export function createTelegramActionGate(params: { const accountId = normalizeAccountId(params.accountId); return createAccountActionGate({ baseActions: params.cfg.channels?.telegram?.actions, - accountActions: resolveAccountConfig(params.cfg, accountId)?.actions, + accountActions: resolveTelegramAccountConfig(params.cfg, accountId)?.actions, }); } diff --git a/src/telegram/bot-message-context.topic-agentid.test.ts b/src/telegram/bot-message-context.topic-agentid.test.ts index b3b634b4768..d3e24060278 100644 --- a/src/telegram/bot-message-context.topic-agentid.test.ts +++ b/src/telegram/bot-message-context.topic-agentid.test.ts @@ -21,58 +21,51 @@ vi.mock("../config/config.js", async (importOriginal) => { }); describe("buildTelegramMessageContext per-topic agentId routing", () => { + function buildForumMessage(threadId = 3) { + return { + message_id: 1, + chat: { + id: -1001234567890, + type: "supergroup" as const, + title: "Forum", + is_forum: true, + }, + date: 1700000000, + text: "@bot hello", + message_thread_id: threadId, + from: { id: 42, first_name: "Alice" }, + }; + } + + async function buildForumContext(params: { + threadId?: number; + topicConfig?: Record; + }) { + return await buildTelegramMessageContextForTest({ + message: buildForumMessage(params.threadId), + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + ...(params.topicConfig ? { topicConfig: params.topicConfig } : {}), + }), + }); + } + beforeEach(() => { vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never); }); it("uses group-level agent when no topic agentId is set", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { systemPrompt: "Be nice" }, - }), - }); + const ctx = await buildForumContext({ topicConfig: { systemPrompt: "Be nice" } }); expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:3"); }); it("routes to topic-specific agent when agentId is set", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, - }), + const ctx = await buildForumContext({ + topicConfig: { agentId: "zu", systemPrompt: "I am Zu" }, }); expect(ctx).not.toBeNull(); @@ -82,27 +75,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { it("different topics route to different agents", async () => { const buildForTopic = async (threadId: number, agentId: string) => - await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: threadId, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId }, - }), - }); + await buildForumContext({ threadId, topicConfig: { agentId } }); const ctxA = await buildForTopic(1, "main"); const ctxB = await buildForTopic(3, "zu"); @@ -117,26 +90,8 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { }); it("ignores whitespace-only agentId and uses group-level agent", async () => { - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: " ", systemPrompt: "Be nice" }, - }), + const ctx = await buildForumContext({ + topicConfig: { agentId: " ", systemPrompt: "Be nice" }, }); expect(ctx).not.toBeNull(); @@ -152,27 +107,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { messages: { groupChat: { mentionPatterns: [] } }, } as never); - const ctx = await buildTelegramMessageContextForTest({ - message: { - message_id: 1, - chat: { - id: -1001234567890, - type: "supergroup", - title: "Forum", - is_forum: true, - }, - date: 1700000000, - text: "@bot hello", - message_thread_id: 3, - from: { id: 42, first_name: "Alice" }, - }, - options: { forceWasMentioned: true }, - resolveGroupActivation: () => true, - resolveTelegramGroupConfig: () => ({ - groupConfig: { requireMention: false }, - topicConfig: { agentId: "ghost" }, - }), - }); + const ctx = await buildForumContext({ topicConfig: { agentId: "ghost" } }); expect(ctx).not.toBeNull(); expect(ctx?.ctxPayload?.SessionKey).toContain("agent:main:"); diff --git a/src/telegram/bot-native-commands.session-meta.test.ts b/src/telegram/bot-native-commands.session-meta.test.ts index 9f0a9f4116d..1b05ddd0d9c 100644 --- a/src/telegram/bot-native-commands.session-meta.test.ts +++ b/src/telegram/bot-native-commands.session-meta.test.ts @@ -131,37 +131,22 @@ function registerAndResolveStatusHandler(params: { sendMessage: ReturnType; } { const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params; - const commandHandlers = new Map(); - const sendMessage = vi.fn().mockResolvedValue(undefined); - registerTelegramNativeCommands({ - ...createNativeCommandTestParams({ - bot: { - api: { - setMyCommands: vi.fn().mockResolvedValue(undefined), - sendMessage, - }, - command: vi.fn((name: string, cb: TelegramCommandHandler) => { - commandHandlers.set(name, cb); - }), - } as unknown as Parameters[0]["bot"], - cfg, - allowFrom: allowFrom ?? ["*"], - groupAllowFrom: groupAllowFrom ?? [], - resolveTelegramGroupConfig, - }), + return registerAndResolveCommandHandlerBase({ + commandName: "status", + cfg, + allowFrom: allowFrom ?? ["*"], + groupAllowFrom: groupAllowFrom ?? [], + useAccessGroups: true, + resolveTelegramGroupConfig, }); - - const handler = commandHandlers.get("status"); - expect(handler).toBeTruthy(); - return { handler: handler as TelegramCommandHandler, sendMessage }; } -function registerAndResolveCommandHandler(params: { +function registerAndResolveCommandHandlerBase(params: { commandName: string; cfg: OpenClawConfig; - allowFrom?: string[]; - groupAllowFrom?: string[]; - useAccessGroups?: boolean; + allowFrom: string[]; + groupAllowFrom: string[]; + useAccessGroups: boolean; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; }): { handler: TelegramCommandHandler; @@ -189,9 +174,9 @@ function registerAndResolveCommandHandler(params: { }), } as unknown as Parameters[0]["bot"], cfg, - allowFrom: allowFrom ?? [], - groupAllowFrom: groupAllowFrom ?? [], - useAccessGroups: useAccessGroups ?? true, + allowFrom, + groupAllowFrom, + useAccessGroups, resolveTelegramGroupConfig, }), }); @@ -201,6 +186,72 @@ function registerAndResolveCommandHandler(params: { return { handler: handler as TelegramCommandHandler, sendMessage }; } +function registerAndResolveCommandHandler(params: { + commandName: string; + cfg: OpenClawConfig; + allowFrom?: string[]; + groupAllowFrom?: string[]; + useAccessGroups?: boolean; + resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; +}): { + handler: TelegramCommandHandler; + sendMessage: ReturnType; +} { + const { + commandName, + cfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveTelegramGroupConfig, + } = params; + return registerAndResolveCommandHandlerBase({ + commandName, + cfg, + allowFrom: allowFrom ?? [], + groupAllowFrom: groupAllowFrom ?? [], + useAccessGroups: useAccessGroups ?? true, + resolveTelegramGroupConfig, + }); +} + +function createConfiguredAcpTopicBinding(boundSessionKey: string) { + return { + spec: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + agentId: "codex", + mode: "persistent", + }, + record: { + bindingId: "config:acp:telegram:default:-1001234567890:topic:42", + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-1001234567890:topic:42", + parentConversationId: "-1001234567890", + }, + status: "active", + boundAt: 0, + }, + } satisfies import("../acp/persistent-bindings.js").ResolvedConfiguredAcpBinding; +} + +function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType) { + expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); + expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + -1001234567890, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); +} + describe("registerTelegramNativeCommands — session metadata", () => { beforeEach(() => { persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockClear(); @@ -254,29 +305,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("routes Telegram native commands through configured ACP topic bindings", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, sessionKey: boundSessionKey, @@ -359,29 +390,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("aborts native command dispatch when configured ACP topic binding cannot initialize", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: false, sessionKey: boundSessionKey, @@ -405,29 +416,9 @@ describe("registerTelegramNativeCommands — session metadata", () => { it("keeps /new blocked in ACP-bound Telegram topics when sender is unauthorized", async () => { const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface"; - persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue({ - spec: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - agentId: "codex", - mode: "persistent", - }, - record: { - bindingId: "config:acp:telegram:default:-1001234567890:topic:42", - targetSessionKey: boundSessionKey, - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-1001234567890:topic:42", - parentConversationId: "-1001234567890", - }, - status: "active", - boundAt: 0, - }, - }); + persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue( + createConfiguredAcpTopicBinding(boundSessionKey), + ); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({ ok: true, sessionKey: boundSessionKey, @@ -442,14 +433,7 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(buildStatusTopicCommandContext()); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - -1001234567890, - "You are not authorized to use this command.", - expect.objectContaining({ message_thread_id: 42 }), - ); + expectUnauthorizedNewCommandBlocked(sendMessage); }); it("keeps /new blocked for unbound Telegram topics when sender is unauthorized", async () => { @@ -464,13 +448,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { }); await handler(buildStatusTopicCommandContext()); - expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).not.toHaveBeenCalled(); - expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled(); - expect(sendMessage).toHaveBeenCalledWith( - -1001234567890, - "You are not authorized to use this command.", - expect.objectContaining({ message_thread_id: 42 }), - ); + expectUnauthorizedNewCommandBlocked(sendMessage); }); }); diff --git a/src/test-utils/channel-plugin-test-fixtures.ts b/src/test-utils/channel-plugin-test-fixtures.ts new file mode 100644 index 00000000000..39f5a617787 --- /dev/null +++ b/src/test-utils/channel-plugin-test-fixtures.ts @@ -0,0 +1,24 @@ +import type { ChannelPlugin } from "../channels/plugins/types.js"; + +export function makeDirectPlugin(params: { + id: string; + label: string; + docsPath: string; + config: ChannelPlugin["config"]; +}): ChannelPlugin { + return { + id: params.id, + meta: { + id: params.id, + label: params.label, + selectionLabel: params.label, + docsPath: params.docsPath, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: params.config, + actions: { + listActions: () => ["send"], + }, + }; +} diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 2113abc7eb5..204172e4fb2 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -21,6 +21,67 @@ async function fileExists(filePath: string): Promise { } } +type ModeExecProviderFixture = { + tokenMarker: string; + passwordMarker: string; + providers: { + tokenProvider: { + source: "exec"; + command: string; + args: string[]; + allowInsecurePath: true; + }; + passwordProvider: { + source: "exec"; + command: string; + args: string[]; + allowInsecurePath: true; + }; + }; +}; + +async function withModeExecProviderFixture( + label: string, + run: (fixture: ModeExecProviderFixture) => Promise, +) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-tui-mode-${label}-`)); + const tokenMarker = path.join(tempDir, "token-provider-ran"); + const passwordMarker = path.join(tempDir, "password-provider-ran"); + const tokenExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", // pragma: allowlist secret + ].join(""); + const passwordExecProgram = [ + "const fs=require('node:fs');", + `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, + "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", // pragma: allowlist secret + ].join(""); + + try { + await run({ + tokenMarker, + passwordMarker, + providers: { + tokenProvider: { + source: "exec", + command: process.execPath, + args: ["-e", tokenExecProgram], + allowInsecurePath: true, + }, + passwordProvider: { + source: "exec", + command: process.execPath, + args: ["-e", passwordExecProgram], + allowInsecurePath: true, + }, + }, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + describe("resolveGatewayConnection", () => { let envSnapshot: ReturnType; @@ -259,108 +320,56 @@ describe("resolveGatewayConnection", () => { }); it("resolves only token SecretRef when gateway.auth.mode is token", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-token-")); - const tokenMarker = path.join(tempDir, "token-provider-ran"); - const passwordMarker = path.join(tempDir, "password-provider-ran"); - const tokenExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", // pragma: allowlist secret - ].join(""); - const passwordExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", // pragma: allowlist secret - ].join(""); - - loadConfig.mockReturnValue({ - secrets: { - providers: { - tokenProvider: { - source: "exec", - command: process.execPath, - args: ["-e", tokenExecProgram], - allowInsecurePath: true, + await withModeExecProviderFixture( + "token", + async ({ tokenMarker, passwordMarker, providers }) => { + loadConfig.mockReturnValue({ + secrets: { + providers, }, - passwordProvider: { - source: "exec", - command: process.execPath, - args: ["-e", passwordExecProgram], - allowInsecurePath: true, + gateway: { + mode: "local", + auth: { + mode: "token", + token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, + password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, + }, }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "token", - token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, - password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, - }, - }, - }); + }); - try { - const result = await resolveGatewayConnection({}); - expect(result.token).toBe("token-from-exec"); - expect(result.password).toBeUndefined(); - expect(await fileExists(tokenMarker)).toBe(true); - expect(await fileExists(passwordMarker)).toBe(false); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const result = await resolveGatewayConnection({}); + expect(result.token).toBe("token-from-exec"); + expect(result.password).toBeUndefined(); + expect(await fileExists(tokenMarker)).toBe(true); + expect(await fileExists(passwordMarker)).toBe(false); + }, + ); }); it("resolves only password SecretRef when gateway.auth.mode is password", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-mode-password-")); - const tokenMarker = path.join(tempDir, "token-provider-ran"); - const passwordMarker = path.join(tempDir, "password-provider-ran"); - const tokenExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(tokenMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { TOKEN_SECRET: 'token-from-exec' } }));", // pragma: allowlist secret - ].join(""); - const passwordExecProgram = [ - "const fs=require('node:fs');", - `fs.writeFileSync(${JSON.stringify(passwordMarker)},'1');`, - "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", // pragma: allowlist secret - ].join(""); - - loadConfig.mockReturnValue({ - secrets: { - providers: { - tokenProvider: { - source: "exec", - command: process.execPath, - args: ["-e", tokenExecProgram], - allowInsecurePath: true, + await withModeExecProviderFixture( + "password", + async ({ tokenMarker, passwordMarker, providers }) => { + loadConfig.mockReturnValue({ + secrets: { + providers, }, - passwordProvider: { - source: "exec", - command: process.execPath, - args: ["-e", passwordExecProgram], - allowInsecurePath: true, + gateway: { + mode: "local", + auth: { + mode: "password", + token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, + password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, + }, }, - }, - }, - gateway: { - mode: "local", - auth: { - mode: "password", - token: { source: "exec", provider: "tokenProvider", id: "TOKEN_SECRET" }, - password: { source: "exec", provider: "passwordProvider", id: "PASSWORD_SECRET" }, - }, - }, - }); + }); - try { - const result = await resolveGatewayConnection({}); - expect(result.password).toBe("password-from-exec"); - expect(result.token).toBeUndefined(); - expect(await fileExists(tokenMarker)).toBe(false); - expect(await fileExists(passwordMarker)).toBe(true); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const result = await resolveGatewayConnection({}); + expect(result.password).toBe("password-from-exec"); + expect(result.token).toBeUndefined(); + expect(await fileExists(tokenMarker)).toBe(false); + expect(await fileExists(passwordMarker)).toBe(true); + }, + ); }); }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index a595cd7a70d..4001cba4008 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -369,7 +369,7 @@ export async function resolveGatewayConnection( }; } - if (gatewayAuthMode === "token") { + const resolveToken = async () => { const localToken = explicitAuth.token || envToken ? { value: explicitAuth.token ?? envToken } @@ -385,6 +385,11 @@ export async function resolveGatewayConnection( localToken.unresolvedRefReason ?? "Missing gateway auth token.", ); } + return token; + }; + + if (gatewayAuthMode === "token") { + const token = await resolveToken(); return { url, token, @@ -418,21 +423,7 @@ export async function resolveGatewayConnection( }; } - const localToken = - explicitAuth.token || envToken - ? { value: explicitAuth.token ?? envToken } - : await resolveConfiguredSecretInputString({ - value: config.gateway?.auth?.token, - path: "gateway.auth.token", - env, - config, - }); - const token = explicitAuth.token ?? envToken ?? localToken.value; - if (!token) { - throwGatewayAuthResolutionError( - localToken.unresolvedRefReason ?? "Missing gateway auth token.", - ); - } + const token = await resolveToken(); return { url, token, diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index 94b550b2b2a..ce3c9700d7b 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -127,6 +127,32 @@ describe("web processMessage inbound contract", () => { } }); + async function processSelfDirectMessage(cfg: unknown) { + capturedDispatchParams = undefined; + await processMessage( + makeProcessMessageArgs({ + routeSessionKey: "agent:main:whatsapp:direct:+1555", + groupHistoryKey: "+1555", + cfg, + msg: { + id: "msg1", + from: "+1555", + to: "+1555", + selfE164: "+1555", + chatType: "direct", + body: "hi", + }, + }), + ); + } + + function getDispatcherResponsePrefix() { + // oxlint-disable-next-line typescript/no-explicit-any + const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; + // oxlint-disable-next-line typescript/no-explicit-any + return dispatcherOptions?.responsePrefix as string | undefined; + } + it("passes a finalized MsgContext to the dispatcher", async () => { await processMessage( makeProcessMessageArgs({ @@ -184,66 +210,30 @@ describe("web processMessage inbound contract", () => { }); it("defaults responsePrefix to identity name in self-chats when unset", async () => { - capturedDispatchParams = undefined; - - await processMessage( - makeProcessMessageArgs({ - routeSessionKey: "agent:main:whatsapp:direct:+1555", - groupHistoryKey: "+1555", - cfg: { - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - ], + await processSelfDirectMessage({ + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, }, - messages: {}, - session: { store: sessionStorePath }, - } as unknown as ReturnType, - msg: { - id: "msg1", - from: "+1555", - to: "+1555", - selfE164: "+1555", - chatType: "direct", - body: "hi", - }, - }), - ); + ], + }, + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType); - // oxlint-disable-next-line typescript/no-explicit-any - const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; - expect(dispatcherOptions?.responsePrefix).toBe("[Mainbot]"); + expect(getDispatcherResponsePrefix()).toBe("[Mainbot]"); }); it("does not force an [openclaw] response prefix in self-chats when identity is unset", async () => { - capturedDispatchParams = undefined; + await processSelfDirectMessage({ + messages: {}, + session: { store: sessionStorePath }, + } as unknown as ReturnType); - await processMessage( - makeProcessMessageArgs({ - routeSessionKey: "agent:main:whatsapp:direct:+1555", - groupHistoryKey: "+1555", - cfg: { - messages: {}, - session: { store: sessionStorePath }, - } as unknown as ReturnType, - msg: { - id: "msg1", - from: "+1555", - to: "+1555", - selfE164: "+1555", - chatType: "direct", - body: "hi", - }, - }), - ); - - // oxlint-disable-next-line typescript/no-explicit-any - const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; - expect(dispatcherOptions?.responsePrefix).toBeUndefined(); + expect(getDispatcherResponsePrefix()).toBeUndefined(); }); it("clears pending group history when the dispatcher does not queue a final reply", async () => { diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index fc442389132..56e805cee66 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -351,7 +351,7 @@ export async function finalizeOnboardingWizard( "Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.", `View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`, `Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`, - "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).", + "Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.", `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, "If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).", ].join("\n"), diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts index bdde68f1cb2..1345b8f4954 100644 --- a/src/wizard/onboarding.gateway-config.test.ts +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -145,7 +145,7 @@ describe("configureGatewayForOnboarding", () => { it("honors secretInputMode=ref for gateway password prompts", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; - process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-secret"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-secret"; // pragma: allowlist secret try { const prompter = createPrompter({ selectQueue: ["loopback", "password", "off", "env"], @@ -159,7 +159,7 @@ describe("configureGatewayForOnboarding", () => { nextConfig: {}, localPort: 18789, quickstartGateway: createQuickstartGateway("password"), - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret prompter, runtime, }); @@ -195,7 +195,7 @@ describe("configureGatewayForOnboarding", () => { nextConfig: {}, localPort: 18789, quickstartGateway: createQuickstartGateway("token"), - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret prompter, runtime, }); diff --git a/src/wizard/onboarding.secret-input.test.ts b/src/wizard/onboarding.secret-input.test.ts index 29c9d5c11c9..4258d6df6cd 100644 --- a/src/wizard/onboarding.secret-input.test.ts +++ b/src/wizard/onboarding.secret-input.test.ts @@ -19,7 +19,7 @@ describe("resolveOnboardingSecretInputString", () => { value: "${OPENCLAW_GATEWAY_PASSWORD}", path: "gateway.auth.password", env: { - OPENCLAW_GATEWAY_PASSWORD: "gateway-secret", + OPENCLAW_GATEWAY_PASSWORD: "gateway-secret", // pragma: allowlist secret }, }); diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index ecc9c47060e..e6bbfd146fa 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -400,7 +400,7 @@ describe("runOnboardingWizard", () => { it("resolves gateway.auth.password SecretRef for local onboarding probe", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; - process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; // pragma: allowlist secret probeGatewayReachable.mockClear(); readConfigFileSnapshot.mockResolvedValueOnce({ path: "/tmp/.openclaw/openclaw.json", @@ -462,7 +462,7 @@ describe("runOnboardingWizard", () => { expect(probeGatewayReachable).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789", - password: "gateway-ref-password", + password: "gateway-ref-password", // pragma: allowlist secret }), ); }); @@ -484,7 +484,7 @@ describe("runOnboardingWizard", () => { skipSearch: true, skipHealth: true, skipUi: true, - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }, runtime, prompter, @@ -492,7 +492,7 @@ describe("runOnboardingWizard", () => { expect(configureGatewayForOnboarding).toHaveBeenCalledWith( expect.objectContaining({ - secretInputMode: "ref", + secretInputMode: "ref", // pragma: allowlist secret }), ); }); diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 25e78e12408..393d13a8f97 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -365,7 +365,7 @@ describe("config form renderer", () => { "models.providers.*.apiKey": { sensitive: true }, }, unsupportedPaths: analysis.unsupportedPaths, - value: { models: { providers: { openai: { apiKey: "old" } } } }, + value: { models: { providers: { openai: { apiKey: "old" } } } }, // pragma: allowlist secret onPatch, }), container, diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 853bc58b6e4..8dae3fc2a13 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -151,6 +151,9 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -167,12 +170,18 @@ describe("control UI routing", () => { it("hydrates token from URL params even when settings already set", async () => { localStorage.setItem( "openclaw.control.settings.v1", - JSON.stringify({ token: "existing-token" }), + JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }), ); const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ + gatewayUrl: "wss://gateway.example/openclaw", + }); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -182,6 +191,9 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 18b91c6a898..34563291fe3 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -24,40 +24,147 @@ function createStorageMock(): Storage { }; } +function setTestLocation(params: { protocol: string; host: string; pathname: string }) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState({}, "", params.pathname); + return; + } + vi.stubGlobal("location", { + protocol: params.protocol, + host: params.host, + pathname: params.pathname, + } as Location); +} + +function setControlUiBasePath(value: string | undefined) { + if (typeof window === "undefined") { + vi.stubGlobal( + "window", + value == null + ? ({} as Window & typeof globalThis) + : ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis), + ); + return; + } + if (value == null) { + delete window.__OPENCLAW_CONTROL_UI_BASE_PATH__; + return; + } + Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", { + value, + writable: true, + configurable: true, + }); +} + +function expectedGatewayUrl(basePath: string): string { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}${basePath}`; +} + describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); vi.stubGlobal("localStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + localStorage.clear(); + setControlUiBasePath(undefined); }); afterEach(() => { vi.restoreAllMocks(); + setControlUiBasePath(undefined); vi.unstubAllGlobals(); }); it("uses configured base path and normalizes trailing slash", async () => { - vi.stubGlobal("location", { + setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/ignored/path", - } as Location); - vi.stubGlobal("window", { __OPENCLAW_CONTROL_UI_BASE_PATH__: " /openclaw/ " } as Window & - typeof globalThis); + }); + setControlUiBasePath(" /openclaw/ "); const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("wss://gateway.example:8443/openclaw"); + expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/openclaw")); }); it("infers base path from nested pathname when configured base path is not set", async () => { - vi.stubGlobal("location", { + setTestLocation({ protocol: "http:", host: "gateway.example:18789", pathname: "/apps/openclaw/chat", - } as Location); - vi.stubGlobal("window", {} as Window & typeof globalThis); + }); const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("ws://gateway.example:18789/apps/openclaw"); + expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw")); + }); + + it("ignores and scrubs legacy persisted tokens", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "persisted-token", + sessionKey: "agent", + }), + ); + + const { loadSettings } = await import("./storage.ts"); + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "agent", + }); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + sessionKey: "agent", + lastActiveSessionKey: "agent", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + }); + + it("does not persist gateway tokens when saving settings", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "memory-only-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 757dc9eab7f..b413cf38eb5 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,5 +1,7 @@ const KEY = "openclaw.control.settings.v1"; +type PersistedUiSettings = Omit & { token?: never }; + import { isSupportedLocale } from "../i18n/index.ts"; import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts"; import type { ThemeMode } from "./theme.ts"; @@ -50,12 +52,13 @@ export function loadSettings(): UiSettings { return defaults; } const parsed = JSON.parse(raw) as Partial; - return { + const settings = { gatewayUrl: typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() ? parsed.gatewayUrl.trim() : defaults.gatewayUrl, - token: typeof parsed.token === "string" ? parsed.token : defaults.token, + // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. + token: defaults.token, sessionKey: typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() ? parsed.sessionKey.trim() @@ -89,11 +92,31 @@ export function loadSettings(): UiSettings { : defaults.navGroupsCollapsed, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, }; + if ("token" in parsed) { + persistSettings(settings); + } + return settings; } catch { return defaults; } } export function saveSettings(next: UiSettings) { - localStorage.setItem(KEY, JSON.stringify(next)); + persistSettings(next); +} + +function persistSettings(next: UiSettings) { + const persisted: PersistedUiSettings = { + gatewayUrl: next.gatewayUrl, + sessionKey: next.sessionKey, + lastActiveSessionKey: next.lastActiveSessionKey, + theme: next.theme, + chatFocusMode: next.chatFocusMode, + chatShowThinking: next.chatShowThinking, + splitRatio: next.splitRatio, + navCollapsed: next.navCollapsed, + navGroupsCollapsed: next.navGroupsCollapsed, + ...(next.locale ? { locale: next.locale } : {}), + }; + localStorage.setItem(KEY, JSON.stringify(persisted)); }